Test Failed
Pull Request — master (#263)
by Sergei
14:30
created

Container::get()   B

Complexity

Conditions 11
Paths 20

Size

Total Lines 46
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 11

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 21
cts 21
cp 1
crap 11
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 136
    public function __construct(ContainerConfigInterface $config)
78
    {
79 136
        $this->definitions = new DefinitionStorage(
80
            [
81 136
                ContainerInterface::class => $this,
82
                StateResetter::class => StateResetter::class,
83
            ],
84 136
            $config->useStrictMode()
85
        );
86 136
        $this->validate = $config->shouldValidate();
87 136
        $this->setTags($config->getTags());
88 133
        $this->addDefinitions($config->getDefinitions());
89 124
        $this->addProviders($config->getProviders());
90 118
        $this->setDelegates($config->getDelegates());
91
    }
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 addDefinition()
101
     */
102 48
    public function has(string $id): bool
103
    {
104
        if (TagHelper::isTagAlias($id)) {
105 48
            $tag = TagHelper::extractTagFromAlias($id);
106 1
            return isset($this->tags[$tag]);
107
        }
108
109 47
        try {
110 3
            return $this->definitions->has($id);
111 3
        } catch (CircularReferenceException $e) {
112
            return true;
113
        }
114
    }
115 44
116 3
    /**
117 3
     * 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
    {
136
        if (!array_key_exists($id, $this->instances)) {
137
            try {
138
                $this->instances[$id] = $this->build($id);
139 100
            } catch (NotFoundException $e) {
140
                if (!$this->delegates->has($id)) {
141
                    throw $e;
142 100
                }
143 1
144 1
                /** @psalm-suppress MixedReturnStatement */
145
                return $this->delegates->get($id);
146 1
            }
147
        }
148
149
        if ($id === StateResetter::class) {
150
            $delegatesResetter = null;
151 99
            if ($this->delegates->has(StateResetter::class)) {
152
                $delegatesResetter = $this->delegates->get(StateResetter::class);
153 99
            }
154 20
155 11
            /** @var StateResetter $mainResetter */
156 8
            $mainResetter = $this->instances[$id];
157
158
            if ($this->useResettersFromMeta) {
159
                /** @var StateResetter[] $resetters */
160 3
                $resetters = [];
161
                foreach ($this->resetters as $serviceId => $callback) {
162
                    if (isset($this->instances[$serviceId])) {
163
                        $resetters[$serviceId] = $callback;
164 90
                    }
165 10
                }
166 10
                if ($delegatesResetter !== null) {
167 2
                    $resetters[] = $delegatesResetter;
168
                }
169
                $mainResetter->setResetters($resetters);
170
            } elseif ($delegatesResetter !== null) {
171 10
                $resetter = new StateResetter($this->get(ContainerInterface::class));
172
                $resetter->setResetters([$mainResetter, $delegatesResetter]);
173 10
174
                return $resetter;
175 7
            }
176 7
        }
177 7
178 7
        /** @psalm-suppress MixedReturnStatement */
179
        return $this->instances[$id];
180
    }
181 7
182 1
    /**
183
     * Sets a definition to the container. Definition may be defined multiple ways.
184 7
     *
185 5
     * @param string $id ID to set definition for.
186 1
     * @param mixed $definition Definition to set.
187 1
     *
188
     * @throws InvalidConfigException
189 1
     *
190
     * @see DefinitionNormalizer::normalize()
191
     */
192
    private function addDefinition(string $id, $definition): void
193
    {
194 90
        /** @var mixed $definition */
195
        [$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
        if (isset($meta[self::META_TAGS])) {
205
            $this->setDefinitionTags($id, $meta[self::META_TAGS]);
206
        }
207 107
        if (isset($meta[self::META_RESET])) {
208
            $this->setDefinitionResetter($id, $meta[self::META_RESET]);
209
        }
210 107
211 107
        unset($this->instances[$id]);
212 107
        $this->addDefinitionToStorage($id, $definition);
213 105
    }
214
215
    /**
216
     * Sets multiple definitions at once.
217
     *
218
     * @param array $config Definitions indexed by their IDs.
219 99
     *
220 9
     * @throws InvalidConfigException
221
     */
222 99
    private function addDefinitions(array $config): void
223 7
    {
224
        /** @var mixed $definition */
225
        foreach ($config as $id => $definition) {
226 99
            if ($this->validate && !is_string($id)) {
227 99
                throw new InvalidConfigException(
228
                    sprintf(
229
                        'Key must be a string. %s given.',
230
                        $this->getVariableType($id)
231
                    )
232
                );
233
            }
234
            /** @var string $id */
235
236
            $this->addDefinition($id, $definition);
237 133
        }
238
    }
239
240 133
    /**
241 108
     * Set container delegates.
242 1
     *
243 1
     * Each delegate must is a callable in format "function (ContainerInterface $container): ContainerInterface".
244
     * The container instance returned is used in case a service can not be found in primary container.
245 1
     *
246
     * @param array $delegates
247
     *
248
     * @throws InvalidConfigException
249
     */
250
    private function setDelegates(array $delegates): void
251 107
    {
252
        $this->delegates = new CompositeContainer();
253
        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
            /** @var ContainerInterface */
261
            $delegate = $delegate($this);
262
263
            if (!$delegate instanceof ContainerInterface) {
264
                throw new InvalidConfigException(
265 118
                    'Delegate callable must return an object that implements ContainerInterface.'
266
                );
267 118
            }
268 118
269 5
            $this->delegates->attach($delegate);
270 1
        }
271
        $this->definitions->setDelegateContainer($this->delegates);
272
    }
273
274
    /**
275
     * @param mixed $definition Definition to validate.
276 4
     * @param string|null $id ID of the definition to validate.
277
     *
278 4
     * @throws InvalidConfigException
279 1
     */
280
    private function validateDefinition($definition, ?string $id = null): void
281
    {
282
        if (is_array($definition) && isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) {
283
            /** @var mixed $class */
284 3
            $class = $definition['class'];
285
286 116
            /** @var mixed $constructorArguments */
287
            $constructorArguments = $definition['__construct()'];
288
289
            /**
290
             * @var array $methodsAndProperties Is always array for prepared array definition data.
291
             *
292
             * @see DefinitionParser::parse()
293
             */
294
            $methodsAndProperties = $definition['methodsAndProperties'];
295 107
296
            $definition = array_merge(
297 107
                $class === null ? [] : [ArrayDefinition::CLASS_NAME => $class],
298
                [ArrayDefinition::CONSTRUCTOR => $constructorArguments],
299 46
                $methodsAndProperties,
300
            );
301
        }
302 46
303
        if ($definition instanceof ExtensibleService) {
304
            throw new InvalidConfigException(
305
                'Invalid definition. ExtensibleService is only allowed in provider extensions.'
306
            );
307
        }
308
309 46
        DefinitionValidator::validate($definition, $id);
310
    }
311 46
312 46
    /**
313 46
     * @throws InvalidConfigException
314
     */
315
    private function validateMeta(array $meta): void
316
    {
317
        /** @var mixed $value */
318 107
        foreach ($meta as $key => $value) {
319 1
            if (!in_array($key, self::ALLOWED_META, true)) {
320
                throw new InvalidConfigException(
321
                    sprintf(
322
                        'Invalid definition: metadata "%s" is not allowed. Did you mean "%s()" or "$%s"?',
323
                        $key,
324 106
                        $key,
325
                        $key,
326
                    )
327
                );
328
            }
329
330 105
            if ($key === self::META_TAGS) {
331
                $this->validateDefinitionTags($value);
332
            }
333 105
334 22
            if ($key === self::META_RESET) {
335 3
                $this->validateDefinitionReset($value);
336 3
            }
337
        }
338
    }
339
340
    /**
341
     * @param mixed $tags
342
     *
343
     * @throws InvalidConfigException
344
     */
345 20
    private function validateDefinitionTags($tags): void
346 12
    {
347
        if (!is_array($tags)) {
348
            throw new InvalidConfigException(
349 18
                sprintf(
350 8
                    'Invalid definition: tags should be array of strings, %s given.',
351
                    $this->getVariableType($tags)
352
                )
353
            );
354
        }
355
356
        foreach ($tags as $tag) {
357
            if (!is_string($tag)) {
358
                throw new InvalidConfigException('Invalid tag. Expected a string, got ' . var_export($tag, true) . '.');
359
            }
360 12
        }
361
    }
362 12
363 1
    /**
364 1
     * @param mixed $reset
365
     *
366 1
     * @throws InvalidConfigException
367
     */
368
    private function validateDefinitionReset($reset): void
369
    {
370
        if (!$reset instanceof Closure) {
371 11
            throw new InvalidConfigException(
372 11
                sprintf(
373 1
                    'Invalid definition: "reset" should be closure, %s given.',
374
                    $this->getVariableType($reset)
375
                )
376
            );
377
        }
378
    }
379
380
    /**
381
     * @throws InvalidConfigException
382
     */
383 8
    private function setTags(array $tags): void
384
    {
385 8
        if ($this->validate) {
386 1
            foreach ($tags as $tag => $services) {
387 1
                if (!is_string($tag)) {
388
                    throw new InvalidConfigException(
389 1
                        sprintf(
390
                            'Invalid tags configuration: tag should be string, %s given.',
391
                            $tag
392
                        )
393
                    );
394
                }
395
                if (!is_array($services)) {
396
                    throw new InvalidConfigException(
397
                        sprintf(
398 136
                            'Invalid tags configuration: tag should contain array of service IDs, %s given.',
399
                            $this->getVariableType($services)
400 136
                        )
401 136
                    );
402 5
                }
403 1
                /** @var mixed $service */
404 1
                foreach ($services as $service) {
405
                    if (!is_string($service)) {
406
                        throw new InvalidConfigException(
407
                            sprintf(
408
                                'Invalid tags configuration: service should be defined as class string, %s given.',
409
                                $this->getVariableType($service)
410 4
                            )
411 1
                        );
412 1
                    }
413
                }
414 1
            }
415
        }
416
        /** @psalm-var array<string, list<string>> $tags */
417
418
        $this->tags = $tags;
419 3
    }
420 3
421 1
    /**
422 1
     * @psalm-param string[] $tags
423
     */
424 1
    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 133
    private function setDefinitionResetter(string $id, Closure $resetter): void
434
    {
435
        $this->resetters[$id] = $resetter;
436
    }
437
438
    /**
439 9
     * Add definition to storage.
440
     *
441 9
     * @see $definitions
442 9
     *
443 9
     * @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 7
        $this->definitions->set($id, $definition);
449
450 7
        if ($id === StateResetter::class) {
451
            $this->useResettersFromMeta = false;
452
        }
453
    }
454
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 99
     * @throws InvalidConfigException
462
     * @throws NotFoundException
463 99
     *
464
     * @return mixed|object New built instance of the specified class.
465 99
     *
466 5
     * @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
            throw new CircularReferenceException(sprintf(
479
                'Circular reference to "%s" detected while building: %s.',
480
                $id,
481
                implode(', ', array_keys($this->building))
482
            ));
483 99
        }
484
485 99
        $this->building[$id] = 1;
486 10
        try {
487
            /** @var mixed $object */
488
            $object = $this->buildInternal($id);
489 98
        } finally {
490 89
            unset($this->building[$id]);
491 89
        }
492
493 4
        return $object;
494
    }
495
496 4
    private function getTaggedServices(string $tagAlias): array
497
    {
498
        $tag = TagHelper::extractTagFromAlias($tagAlias);
499
        $services = [];
500 98
        if (isset($this->tags[$tag])) {
501
            foreach ($this->tags[$tag] as $service) {
502
                /** @var mixed */
503 98
                $services[] = $this->get($service);
504 89
            }
505 98
        }
506
507
        return $services;
508 89
    }
509
510
    /**
511 10
     * @param string $id
512
     *
513 10
     * @throws InvalidConfigException
514 10
     * @throws NotFoundException
515 10
     *
516 9
     * @return mixed|object
517
     */
518 9
    private function buildInternal(string $id)
519
    {
520
        if ($this->definitions->has($id)) {
521
            $definition = DefinitionNormalizer::normalize($this->definitions->get($id), $id);
522 10
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 98
    private function addProviders(array $providers): void
534
    {
535 98
        $extensions = [];
536 89
        /** @var mixed $provider */
537
        foreach ($providers as $provider) {
538 89
            $providerInstance = $this->buildProvider($provider);
539
            $extensions[] = $providerInstance->getExtensions();
540
            $this->addDefinitions($providerInstance->getDefinitions());
541 11
        }
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 124
                        sprintf('Extension key must be a service ID as string, %s given.', $id)
549
                    );
550 124
                }
551
552 124
                if ($id === ContainerInterface::class) {
553 15
                    throw new InvalidConfigException('ContainerInterface extensions are not allowed.');
554 13
                }
555 13
556
                if (!$this->definitions->has($id)) {
557
                    throw new InvalidConfigException("Extended service \"$id\" doesn't exist.");
558 122
                }
559
560 13
                if (!is_callable($extension)) {
561 10
                    throw new InvalidConfigException(
562 1
                        sprintf(
563 1
                            'Extension of service should be callable, %s given.',
564
                            $this->getVariableType($extension)
565
                        )
566
                    );
567 9
                }
568 1
569
                /** @var mixed $definition */
570
                $definition = $this->definitions->get($id);
571 8
                if (!$definition instanceof ExtensibleService) {
572 1
                    $definition = new ExtensibleService($definition, $id);
573
                    $this->addDefinitionToStorage($id, $definition);
574
                }
575 8
576 1
                $definition->addExtension($extension);
577 1
            }
578
        }
579 1
    }
580
581
    /**
582
     * Builds service provider by definition.
583
     *
584
     * @param mixed $provider Class name or instance of provider.
585 7
     *
586 7
     * @throws InvalidConfigException If provider argument is not valid.
587 7
     *
588 7
     * @return ServiceProviderInterface Instance of service provider.
589
     *
590
     * @psalm-suppress MoreSpecificReturnType
591 7
     */
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 15
         * @psalm-suppress MixedMethodCall Service provider defined as class string
608
         * should container public constructor, otherwise throws error.
609 15
         */
610 1
        $providerInstance = is_object($provider) ? $provider : new $provider();
611 1
        if (!$providerInstance instanceof ServiceProviderInterface) {
612
            throw new InvalidConfigException(
613
                sprintf(
614 1
                    '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 14
    }
626 14
627 1
    /**
628 1
     * @param mixed $variable
629
     */
630
    private function getVariableType($variable): string
631 1
    {
632
        return is_object($variable) ? get_class($variable) : gettype($variable);
633
    }
634
}
635