Passed
Push — iterable ( a06b5c...0e0d10 )
by Dmitriy
05:25 queued 02:23
created

Container::getVariableType()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

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