Test Failed
Pull Request — master (#316)
by Alexander
07:57 queued 05:10
created

Container::get()   C

Complexity

Conditions 14
Paths 27

Size

Total Lines 53
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 14

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 14
eloc 30
c 2
b 0
f 0
nc 27
nop 1
dl 0
loc 53
ccs 23
cts 23
cp 1
crap 14
rs 6.2666

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