Passed
Pull Request — master (#240)
by Dmitriy
02:32
created

Container::get()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 22
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 7.0283

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 7
eloc 11
c 4
b 0
f 0
nc 6
nop 1
dl 0
loc 22
ccs 11
cts 12
cp 0.9167
crap 7.0283
rs 8.8333
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Di;
6
7
use Closure;
8
use Psr\Container\ContainerInterface;
9
use ReflectionClass;
10
use ReflectionException;
11
use ReflectionNamedType;
12
use ReflectionUnionType;
13
use Yiisoft\Di\Contracts\ServiceProviderInterface;
14
use Yiisoft\Factory\Definition\ArrayDefinition;
15
use Yiisoft\Factory\Definition\DefinitionValidator;
16
use Yiisoft\Factory\Exception\CircularReferenceException;
17
use Yiisoft\Factory\Exception\InvalidConfigException;
18
use Yiisoft\Factory\Exception\NotFoundException;
19
use Yiisoft\Factory\Exception\NotInstantiableException;
20
21
use function array_key_exists;
22
use function array_keys;
23
use function class_exists;
24
use function get_class;
25
use function implode;
26
use function in_array;
27
use function is_array;
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 array object definitions indexed by their types
42
     */
43
    private array $definitions = [];
44
    /**
45
     * @var array used to collect ids instantiated during build
46
     * to detect circular references
47
     */
48
    private array $building = [];
49
    private array $resolvableBuilding = [];
50
51
    /**
52
     * @var bool $validate Validate definitions when set
53
     */
54
    private bool $validate;
55
56
    /**
57
     * @var object[]
58
     */
59
    private array $instances = [];
60
61
    private array $tags;
62
63
    private array $resetters = [];
64
    /** @psalm-suppress PropertyNotSetInConstructor */
65
    private DependencyResolver $dependencyResolver;
66
67
    /**
68
     * Container constructor.
69
     *
70
     * @param array $definitions Definitions to put into container.
71
     * @param array $providers Service providers to get definitions from.
72
     * lookup to when resolving dependencies. If provided the current container
73
     * is no longer queried for dependencies.
74
     *
75
     * @throws InvalidConfigException
76
     *
77
     * @psalm-suppress PropertyNotSetInConstructor
78
     */
79 96
    public function __construct(
80
        array $definitions = [],
81
        array $providers = [],
82
        array $tags = [],
83
        bool $validate = true
84
    ) {
85 96
        $this->tags = $tags;
86 96
        $this->validate = $validate;
87 96
        $this->setDefaultDefinitions();
88 96
        $this->setMultiple($definitions);
89 90
        $this->addProviders($providers);
90 89
    }
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
     * @see set()
100
     */
101 29
    public function has($id): bool
102
    {
103
        /** @psalm-suppress  DocblockTypeContradiction */
104 29
        if (!is_string($id)) {
0 ignored issues
show
introduced by
The condition is_string($id) is always true.
Loading history...
105
            return false;
106
        }
107
108 29
        if ($this->isTagAlias($id)) {
109 2
            $tag = substr($id, 4);
110 2
            return isset($this->tags[$tag]);
111
        }
112
113
        try {
114 27
            return $this->isResolvable($id);
115 1
        } catch (CircularReferenceException $e) {
116 1
            return false;
117
        }
118
    }
119
120
    /**
121
     * Returns an instance by either interface name or alias.
122
     *
123
     * Same instance of the class will be returned each time this method is called.
124
     *
125
     * @param string $id The interface or an alias name that was previously registered.
126
     *
127
     * @throws CircularReferenceException
128
     * @throws InvalidConfigException
129
     * @throws NotFoundException
130
     * @throws NotInstantiableException
131
     *
132
     * @return mixed|object An instance of the requested interface.
133
     *
134
     * @psalm-template T
135
     * @psalm-param string|class-string<T> $id
136
     * @psalm-return ($id is class-string ? T : mixed)
137
     */
138 83
    public function get($id)
139
    {
140
        /** @psalm-suppress TypeDoesNotContainType */
141 83
        if (!is_string($id)) {
0 ignored issues
show
introduced by
The condition is_string($id) is always true.
Loading history...
142
            throw new \RuntimeException("Id must be string, {$this->getVariableType($id)} given.");
143
        }
144
145 83
        if ($id === StateResetter::class && !isset($this->definitions[$id])) {
146 4
            $resetters = [];
147 4
            foreach ($this->resetters as $serviceId => $callback) {
148 4
                if (isset($this->instances[$serviceId])) {
149 4
                    $resetters[] = $callback->bindTo($this->instances[$serviceId], get_class($this->instances[$serviceId]));
150
                }
151
            }
152 4
            return new StateResetter($resetters, $this);
153
        }
154
155 83
        if (!array_key_exists($id, $this->instances)) {
156 83
            $this->instances[$id] = $this->build($id);
157
        }
158
159 79
        return $this->instances[$id];
160
    }
161
162
    /**
163
     * @param string $id class name, interface name or alias name
164
     *
165
     * @throws CircularReferenceException
166
     */
167 58
    private function isResolvable(string $id): bool
168
    {
169 58
        if (isset($this->definitions[$id]) || $id === StateResetter::class) {
170 27
            return true;
171
        }
172
173 55
        if (!class_exists($id)) {
174 12
            return false;
175
        }
176
177 50
        if (isset($this->resolvableBuilding[$id])) {
178 3
            throw new CircularReferenceException(sprintf(
179 3
                'Circular reference to "%s" detected while building: %s.',
180
                $id,
181 3
                implode(', ', array_keys($this->resolvableBuilding))
182
            ));
183
        }
184
185
        try {
186 50
            $reflectionClass = new ReflectionClass($id);
187
        } catch (ReflectionException $e) {
188
            return false;
189
        }
190
191 50
        if (!$reflectionClass->isInstantiable()) {
192
            return false;
193
        }
194
195 50
        $constructor = $reflectionClass->getConstructor();
196
197 50
        if ($constructor === null) {
198 5
            return true;
199
        }
200
201 48
        $isResolvable = true;
202 48
        $this->resolvableBuilding[$id] = 1;
203
204 48
        foreach ($constructor->getParameters() as $parameter) {
205 48
            $type = $parameter->getType();
206
207 48
            if ($parameter->isVariadic() || $parameter->isOptional()) {
208 43
                break;
209
            }
210
211
            /**
212
             * @var ReflectionNamedType|ReflectionUnionType|null $type
213
             * @psalm-suppress RedundantConditionGivenDocblockType
214
             * @psalm-suppress UndefinedClass
215
             */
216 18
            if ($type === null || !$type instanceof ReflectionUnionType && $type->isBuiltin()) {
217 4
                $isResolvable = false;
218 4
                break;
219
            }
220
221
            // PHP 8 union type is used as type hint
222
            /** @psalm-suppress UndefinedClass, TypeDoesNotContainType */
223 18
            if ($type instanceof ReflectionUnionType) {
224
                $isUnionTypeResolvable = false;
225
                /** @var ReflectionNamedType $unionType */
226
                foreach ($type->getTypes() as $unionType) {
227
                    if (!$unionType->isBuiltin()) {
228
                        $typeName = $unionType->getName();
229
                        if ($typeName === 'self') {
230
                            continue;
231
                        }
232
                        if ($this->isResolvable($typeName)) {
233
                            $isUnionTypeResolvable = true;
234
                            break;
235
                        }
236
                    }
237
                }
238
239
                $isResolvable = $isUnionTypeResolvable;
240
                if (!$isResolvable) {
241
                    break;
242
                }
243
                continue;
244
            }
245
246
            /** @var ReflectionNamedType|null $type */
247
            // Our parameter has a class type hint
248 18
            if ($type !== null && !$type->isBuiltin()) {
249 18
                $typeName = $type->getName();
250
251 18
                if ($typeName === 'self' || !$this->isResolvable($typeName)) {
252 6
                    $isResolvable = false;
253 6
                    break;
254
                }
255
            }
256
        }
257
258 45
        if ($isResolvable) {
259 43
            $this->definitions[$id] = $id;
260
        }
261 45
        unset($this->resolvableBuilding[$id]);
262
263 45
        return $isResolvable;
264
    }
265
266
    /**
267
     * Sets a definition to the container. Definition may be defined multiple ways.
268
     *
269
     * @param string $id
270
     * @param mixed $definition
271
     *
272
     * @throws InvalidConfigException
273
     *
274
     * @see `DefinitionNormalizer::normalize()`
275
     */
276 96
    private function set(string $id, $definition): void
277
    {
278 96
        [$definition, $meta] = DefinitionParser::parse($definition);
279 96
        if ($this->validate) {
280 96
            $this->validateDefinition($definition, $id);
281 96
            $this->validateMeta($meta);
282
        }
283
284 96
        if (isset($meta[self::META_TAGS])) {
285 8
            if ($this->validate) {
286 8
                $this->validateTags($meta[self::META_TAGS]);
287
            }
288 8
            $this->setTags($id, $meta[self::META_TAGS]);
289
        }
290 96
        if (isset($meta[self::META_RESET])) {
291 5
            $this->setResetter($id, $meta[self::META_RESET]);
292
        }
293
294 96
        unset($this->instances[$id]);
295 96
        $this->definitions[$id] = $definition;
296 96
    }
297
298
    /**
299
     * Sets multiple definitions at once.
300
     *
301
     * @param array $config definitions indexed by their ids
302
     *
303
     * @throws InvalidConfigException
304
     */
305 96
    private function setMultiple(array $config): void
306
    {
307 96
        foreach ($config as $id => $definition) {
308 88
            if ($this->validate && !is_string($id)) {
309 1
                throw new InvalidConfigException(sprintf('Key must be a string. %s given.', $this->getVariableType($id)));
310
            }
311 87
            $this->set($id, $definition);
312
        }
313 90
    }
314
315 96
    private function setDefaultDefinitions(): void
316
    {
317 96
        $this->set(ContainerInterface::class, $this);
318 96
    }
319
320
    /**
321
     * @param mixed $definition
322
     *
323
     * @throws InvalidConfigException
324
     */
325 96
    private function validateDefinition($definition, ?string $id = null): void
326
    {
327 96
        if (is_array($definition) && isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) {
328 39
            $class = $definition['class'];
329 39
            $constructorArguments = $definition['__construct()'];
330 39
            $methodsAndProperties = $definition['methodsAndProperties'];
331 39
            $definition = array_merge(
332 39
                $class === null ? [] : [ArrayDefinition::CLASS_NAME => $class],
333 39
                [ArrayDefinition::CONSTRUCTOR => $constructorArguments],
334
                $methodsAndProperties,
335
            );
336
        }
337
338 96
        if ($definition instanceof ExtensibleService) {
339
            throw new InvalidConfigException('Invalid definition. ExtensibleService is only allowed in provider extensions.');
340
        }
341
342 96
        DefinitionValidator::validate($definition, $id);
343 96
    }
344
345
    /**
346
     * @throws InvalidConfigException
347
     */
348 96
    private function validateMeta(array $meta): void
349
    {
350 96
        foreach ($meta as $key => $_value) {
351 16
            if (!in_array($key, self::ALLOWED_META, true)) {
352 3
                throw new InvalidConfigException(
353 3
                    sprintf(
354 3
                        'Invalid definition: metadata "%s" is not allowed. Did you mean "%s()" or "$%s"?',
355
                        $key,
356
                        $key,
357
                        $key,
358
                    )
359
                );
360
            }
361
        }
362 96
    }
363
364 8
    private function validateTags(array $tags): void
365
    {
366 8
        foreach ($tags as $tag) {
367 8
            if (!is_string($tag)) {
368
                throw new InvalidConfigException('Invalid tag. Expected a string, got ' . var_export($tag, true) . '.');
369
            }
370
        }
371 8
    }
372
373 8
    private function setTags(string $id, array $tags): void
374
    {
375 8
        foreach ($tags as $tag) {
376 8
            if (!isset($this->tags[$tag]) || !in_array($id, $this->tags[$tag], true)) {
377 8
                $this->tags[$tag][] = $id;
378
            }
379
        }
380 8
    }
381
382 4
    private function setResetter(string $id, Closure $resetter): void
383
    {
384 4
        $this->resetters[$id] = $resetter;
385 4
    }
386
387
    /**
388
     * Creates new instance by either interface name or alias.
389
     *
390
     * @param string $id The interface or an alias name that was previously registered.
391
     *
392
     * @throws CircularReferenceException
393
     * @throws InvalidConfigException
394
     * @throws NotFoundException
395
     *
396
     * @return mixed|object New built instance of the specified class.
397
     *
398
     * @internal
399
     */
400 83
    private function build(string $id)
401
    {
402 83
        if ($this->isTagAlias($id)) {
403 9
            return $this->getTaggedServices($id);
404
        }
405
406 82
        if (isset($this->building[$id])) {
407 78
            if ($id === ContainerInterface::class) {
408 78
                return $this;
409
            }
410 4
            throw new CircularReferenceException(sprintf(
411 4
                'Circular reference to "%s" detected while building: %s.',
412
                $id,
413 4
                implode(',', array_keys($this->building))
414
            ));
415
        }
416
417 82
        $this->building[$id] = 1;
418
        try {
419 82
            $object = $this->buildInternal($id);
420 78
        } finally {
421 82
            unset($this->building[$id]);
422
        }
423
424 78
        return $object;
425
    }
426
427 89
    private function isTagAlias(string $id): bool
428
    {
429 89
        return strpos($id, 'tag@') === 0;
430
    }
431
432 9
    private function getTaggedServices(string $tagAlias): array
433
    {
434 9
        $tag = substr($tagAlias, 4);
435 9
        $services = [];
436 9
        if (isset($this->tags[$tag])) {
437 8
            foreach ($this->tags[$tag] as $service) {
438 8
                $services[] = $this->get($service);
439
            }
440
        }
441
442 9
        return $services;
443
    }
444
445
    /**
446
     * @param string $id
447
     *
448
     * @throws InvalidConfigException
449
     * @throws NotFoundException
450
     *
451
     * @return mixed|object
452
     */
453 82
    private function buildInternal(string $id)
454
    {
455 82
        if (!isset($this->definitions[$id])) {
456 46
            return $this->buildPrimitive($id);
457
        }
458 78
        $definition = DefinitionNormalizer::normalize($this->definitions[$id], $id);
459
        /** @psalm-suppress RedundantPropertyInitializationCheck */
460 78
        $this->dependencyResolver ??= new DependencyResolver($this->get(ContainerInterface::class));
461
462 78
        return $definition->resolve($this->dependencyResolver);
463
    }
464
465
    /**
466
     * @param string $class
467
     *
468
     * @throws InvalidConfigException
469
     * @throws NotFoundException
470
     *
471
     * @return mixed|object
472
     */
473 46
    private function buildPrimitive(string $class)
474
    {
475 46
        if ($this->isResolvable($class)) {
476
            /** @psalm-suppress ArgumentTypeCoercion */
477 41
            $definition = ArrayDefinition::fromPreparedData($class);
478
            /** @psalm-suppress RedundantPropertyInitializationCheck */
479 41
            $this->dependencyResolver ??= new DependencyResolver($this->get(ContainerInterface::class));
480
481 41
            return $definition->resolve($this->dependencyResolver);
482
        }
483
484 3
        throw new NotFoundException($class);
485
    }
486
487 90
    private function addProviders(array $providers): void
488
    {
489 90
        $extensions = [];
490 90
        foreach ($providers as $provider) {
491 5
            $providerInstance = $this->buildProvider($provider);
492 5
            $extensions[] = $providerInstance->getExtensions();
493 5
            $this->addProviderDefinitions($providerInstance);
494
        }
495
496 90
        foreach ($extensions as $providerExtensions) {
497 5
            foreach ($providerExtensions as $id => $extension) {
498 4
                if (!isset($this->definitions[$id])) {
499 1
                    throw new InvalidConfigException("Extended service \"$id\" doesn't exist.");
500
                }
501
502 3
                if (!$this->definitions[$id] instanceof ExtensibleService) {
503 3
                    $this->definitions[$id] = new ExtensibleService($this->definitions[$id]);
504
                }
505
506 3
                $this->definitions[$id]->addExtension($extension);
507
            }
508
        }
509 89
    }
510
511
    /**
512
     * Adds service provider definitions to the container.
513
     *
514
     * @param object $provider
515
     *
516
     * @throws InvalidConfigException
517
     * @throws NotInstantiableException
518
     *
519
     * @see ServiceProviderInterface
520
     */
521 5
    private function addProviderDefinitions($provider): void
522
    {
523 5
        $definitions = $provider->getDefinitions();
524 5
        $this->setMultiple($definitions);
525 5
    }
526
527
    /**
528
     * Builds service provider by definition.
529
     *
530
     * @param mixed $provider Class name or instance of provider.
531
     *
532
     * @throws InvalidConfigException If provider argument is not valid.
533
     *
534
     * @return ServiceProviderInterface Instance of service provider.
535
     *
536
     * @psalm-suppress MoreSpecificReturnType
537
     */
538 5
    private function buildProvider($provider): ServiceProviderInterface
539
    {
540 5
        if ($this->validate && !(is_string($provider) || is_object($provider) && $provider instanceof ServiceProviderInterface)) {
541
            throw new InvalidConfigException(
542
                sprintf(
543
                    'Service provider should be a class name or an instance of %s. %s given.',
544
                    ServiceProviderInterface::class,
545
                    $this->getVariableType($provider)
546
                )
547
            );
548
        }
549
550 5
        $providerInstance = is_object($provider) ? $provider : new $provider();
551 5
        if (!$providerInstance instanceof ServiceProviderInterface) {
552
            throw new InvalidConfigException(
553
                sprintf(
554
                    'Service provider should be an instance of %s. %s given.',
555
                    ServiceProviderInterface::class,
556
                    $this->getVariableType($providerInstance)
557
                )
558
            );
559
        }
560
561
        /**
562
         * @psalm-suppress LessSpecificReturnStatement
563
         */
564 5
        return $providerInstance;
565
    }
566
567
    /**
568
     * @param mixed $variable
569
     */
570 1
    private function getVariableType($variable): string
571
    {
572 1
        if (is_object($variable)) {
573
            return get_class($variable);
574
        }
575
576 1
        return gettype($variable);
577
    }
578
}
579