Passed
Pull Request — master (#240)
by Dmitriy
02:26
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 Yiisoft\Di\Contracts\ServiceProviderInterface;
10
use Yiisoft\Factory\Definition\ArrayDefinition;
11
use Yiisoft\Factory\Definition\DefinitionValidator;
12
use Yiisoft\Factory\Exception\CircularReferenceException;
13
use Yiisoft\Factory\Exception\InvalidConfigException;
14
use Yiisoft\Factory\Exception\NotFoundException;
15
use Yiisoft\Factory\Exception\NotInstantiableException;
16
17
use function array_key_exists;
18
use function array_keys;
19
use function get_class;
20
use function implode;
21
use function in_array;
22
use function is_array;
23
use function is_object;
24
use function is_string;
25
26
/**
27
 * Container implements a [dependency injection](http://en.wikipedia.org/wiki/Dependency_injection) container.
28
 */
29
final class Container implements ContainerInterface
30
{
31
    private const META_TAGS = 'tags';
32
    private const META_RESET = 'reset';
33
    private const ALLOWED_META = [self::META_TAGS, self::META_RESET];
34
35
    /**
36
     * @var DefinitionStorage storage of object definitions
37
     */
38
    private DefinitionStorage $definitions;
39
    /**
40
     * @var array used to collect ids instantiated during build
41
     * to detect circular references
42
     */
43
    private array $building = [];
44
    private array $resolvableBuilding = [];
0 ignored issues
show
introduced by
The private property $resolvableBuilding is not used, and could be removed.
Loading history...
45
46
    /**
47
     * @var bool $validate Validate definitions when set
48
     */
49
    private bool $validate;
50
51
    /**
52
     * @var object[]
53
     */
54
    private array $instances = [];
55
56
    private array $tags;
57
58
    private array $resetters = [];
59
    /** @psalm-suppress PropertyNotSetInConstructor */
60
    private DependencyResolver $dependencyResolver;
61
    private ContainerInterface $originalContainer;
62
63
    /**
64
     * Container constructor.
65
     *
66
     * @param array $definitions Definitions to put into container.
67
     * @param array $providers Service providers to get definitions from.
68
     * lookup to when resolving dependencies. If provided the current container
69
     * is no longer queried for dependencies.
70
     *
71
     * @throws InvalidConfigException
72
     *
73
     * @psalm-suppress PropertyNotSetInConstructor
74
     */
75 97
    public function __construct(
76
        array $definitions = [],
77
        array $providers = [],
78
        array $tags = [],
79
        bool $validate = true
80
    ) {
81 97
        $this->tags = $tags;
82 97
        $this->validate = $validate;
83 97
        $this->definitions = new DefinitionStorage();
84 97
        $this->setDefaultDefinitions();
85 97
        $this->setMultiple($definitions);
86 91
        $this->addProviders($providers);
87 90
        $this->dependencyResolver = new DependencyResolver($this);
88 90
        $this->dependencyResolver = new DependencyResolver($this->get(ContainerInterface::class));
89 90
        $this->definitions->setDelegateContainer($this->get(ContainerInterface::class));
90 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
     * @see set()
100
     */
101 31
    public function has($id): bool
102
    {
103
        /** @psalm-suppress  DocblockTypeContradiction */
104 31
        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 31
        if ($this->isTagAlias($id)) {
109 2
            $tag = substr($id, 4);
110 2
            return isset($this->tags[$tag]);
111
        }
112
113
        try {
114 29
            return $this->definitions->has($id);
115 2
        } catch (CircularReferenceException $e) {
116 2
            return true;
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 90
    public function get($id)
139
    {
140
        /** @psalm-suppress TypeDoesNotContainType */
141 90
        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 90
        if ($id === StateResetter::class && $this->definitions->get($id) === StateResetter::class) {
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 90
        if (!array_key_exists($id, $this->instances)) {
156 90
            $this->instances[$id] = $this->build($id);
157
        }
158
159 90
        return $this->instances[$id];
160
    }
161
162
    /**
163
     * Sets a definition to the container. Definition may be defined multiple ways.
164
     *
165
     * @param string $id
166
     * @param mixed $definition
167
     *
168
     * @throws InvalidConfigException
169
     *
170
     * @see `DefinitionNormalizer::normalize()`
171
     */
172 97
    private function set(string $id, $definition): void
173
    {
174 97
        [$definition, $meta] = DefinitionParser::parse($definition);
175 97
        if ($this->validate) {
176 97
            $this->validateDefinition($definition, $id);
177 97
            $this->validateMeta($meta);
178
        }
179
180 97
        if (isset($meta[self::META_TAGS])) {
181 8
            if ($this->validate) {
182 8
                $this->validateTags($meta[self::META_TAGS]);
183
            }
184 8
            $this->setTags($id, $meta[self::META_TAGS]);
185
        }
186 97
        if (isset($meta[self::META_RESET])) {
187 5
            $this->setResetter($id, $meta[self::META_RESET]);
188
        }
189
190 97
        unset($this->instances[$id]);
191 97
        $this->definitions->set($id, $definition);
192 97
    }
193
194
    /**
195
     * Sets multiple definitions at once.
196
     *
197
     * @param array $config definitions indexed by their ids
198
     *
199
     * @throws InvalidConfigException
200
     */
201 97
    private function setMultiple(array $config): void
202
    {
203 97
        foreach ($config as $id => $definition) {
204 97
            if ($this->validate && !is_string($id)) {
205 1
                throw new InvalidConfigException(sprintf('Key must be a string. %s given.', $this->getVariableType($id)));
206
            }
207 97
            $this->set($id, $definition);
208
        }
209 97
    }
210
211 97
    private function setDefaultDefinitions(): void
212
    {
213 97
        $this->originalContainer = new CompositeContainer();
214 97
        $this->originalContainer->attach($this);
0 ignored issues
show
Bug introduced by
The method attach() does not exist on Psr\Container\ContainerInterface. It seems like you code against a sub-type of Psr\Container\ContainerInterface such as Yiisoft\Di\CompositeContainer. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

214
        $this->originalContainer->/** @scrutinizer ignore-call */ 
215
                                  attach($this);
Loading history...
215 97
        $this->setMultiple([
216 97
            ContainerInterface::class => $this,
217
            StateResetter::class => StateResetter::class,
218
        ]);
219 97
    }
220
221
    /**
222
     * @param mixed $definition
223
     *
224
     * @throws InvalidConfigException
225
     */
226 97
    private function validateDefinition($definition, ?string $id = null): void
227
    {
228 97
        if (is_array($definition) && isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) {
229 39
            $class = $definition['class'];
230 39
            $constructorArguments = $definition['__construct()'];
231 39
            $methodsAndProperties = $definition['methodsAndProperties'];
232 39
            $definition = array_merge(
233 39
                $class === null ? [] : [ArrayDefinition::CLASS_NAME => $class],
234 39
                [ArrayDefinition::CONSTRUCTOR => $constructorArguments],
235
                $methodsAndProperties,
236
            );
237
        }
238
239 97
        if ($definition instanceof ExtensibleService) {
240
            throw new InvalidConfigException('Invalid definition. ExtensibleService is only allowed in provider extensions.');
241
        }
242
243 97
        DefinitionValidator::validate($definition, $id);
244 97
    }
245
246
    /**
247
     * @throws InvalidConfigException
248
     */
249 97
    private function validateMeta(array $meta): void
250
    {
251 97
        foreach ($meta as $key => $_value) {
252 16
            if (!in_array($key, self::ALLOWED_META, true)) {
253 3
                throw new InvalidConfigException(
254 3
                    sprintf(
255 3
                        'Invalid definition: metadata "%s" is not allowed. Did you mean "%s()" or "$%s"?',
256
                        $key,
257
                        $key,
258
                        $key,
259
                    )
260
                );
261
            }
262
        }
263 97
    }
264
265 8
    private function validateTags(array $tags): void
266
    {
267 8
        foreach ($tags as $tag) {
268 8
            if (!is_string($tag)) {
269
                throw new InvalidConfigException('Invalid tag. Expected a string, got ' . var_export($tag, true) . '.');
270
            }
271
        }
272 8
    }
273
274 8
    private function setTags(string $id, array $tags): void
275
    {
276 8
        foreach ($tags as $tag) {
277 8
            if (!isset($this->tags[$tag]) || !in_array($id, $this->tags[$tag], true)) {
278 8
                $this->tags[$tag][] = $id;
279
            }
280
        }
281 8
    }
282
283 4
    private function setResetter(string $id, Closure $resetter): void
284
    {
285 4
        $this->resetters[$id] = $resetter;
286 4
    }
287
288
    /**
289
     * Creates new instance by either interface name or alias.
290
     *
291
     * @param string $id The interface or an alias name that was previously registered.
292
     *
293
     * @throws CircularReferenceException
294
     * @throws InvalidConfigException
295
     * @throws NotFoundException
296
     *
297
     * @return mixed|object New built instance of the specified class.
298
     *
299
     * @internal
300
     */
301 90
    private function build(string $id)
302
    {
303 90
        if ($this->isTagAlias($id)) {
304 9
            return $this->getTaggedServices($id);
305
        }
306
307 90
        if (isset($this->building[$id])) {
308 5
            if ($id === ContainerInterface::class) {
309 1
                return $this;
310
            }
311 4
            throw new CircularReferenceException(sprintf(
312 4
                'Circular reference to "%s" detected while building: %s.',
313
                $id,
314 4
                implode(', ', array_keys($this->building))
315
            ));
316
        }
317
318 90
        $this->building[$id] = 1;
319
        try {
320 90
            $object = $this->buildInternal($id);
321 90
        } finally {
322 90
            unset($this->building[$id]);
323
        }
324
325 90
        return $object;
326
    }
327
328 90
    private function isTagAlias(string $id): bool
329
    {
330 90
        return strpos($id, 'tag@') === 0;
331
    }
332
333 9
    private function getTaggedServices(string $tagAlias): array
334
    {
335 9
        $tag = substr($tagAlias, 4);
336 9
        $services = [];
337 9
        if (isset($this->tags[$tag])) {
338 8
            foreach ($this->tags[$tag] as $service) {
339 8
                $services[] = $this->get($service);
340
            }
341
        }
342
343 9
        return $services;
344
    }
345
346
    /**
347
     * @param string $id
348
     *
349
     * @throws InvalidConfigException
350
     * @throws NotFoundException
351
     *
352
     * @return mixed|object
353
     */
354 90
    private function buildInternal(string $id)
355
    {
356 90
        if ($this->definitions->has($id)) {
357 90
            $definition = DefinitionNormalizer::normalize($this->definitions->get($id), $id);
358
359 90
            return $definition->resolve($this->dependencyResolver);
360
        }
361
362 4
        throw new NotFoundException($id);
363
    }
364
365 91
    private function addProviders(array $providers): void
366
    {
367 91
        $extensions = [];
368 91
        foreach ($providers as $provider) {
369 5
            $providerInstance = $this->buildProvider($provider);
370 5
            $extensions[] = $providerInstance->getExtensions();
371 5
            $this->addProviderDefinitions($providerInstance);
372
        }
373
374 91
        foreach ($extensions as $providerExtensions) {
375 5
            foreach ($providerExtensions as $id => $extension) {
376 4
                if (!$this->definitions->has($id)) {
377 1
                    throw new InvalidConfigException("Extended service \"$id\" doesn't exist.");
378
                }
379
380 4
                $definition = $this->definitions->get($id);
381 4
                if (!$definition instanceof ExtensibleService) {
382 4
                    $definition = new ExtensibleService($definition);
383 4
                    $this->definitions->set($id, $definition);
384
                }
385
386 4
                $definition->addExtension($extension);
387
            }
388
        }
389 90
    }
390
391
    /**
392
     * Adds service provider definitions to the container.
393
     *
394
     * @param object $provider
395
     *
396
     * @throws InvalidConfigException
397
     * @throws NotInstantiableException
398
     *
399
     * @see ServiceProviderInterface
400
     */
401 5
    private function addProviderDefinitions($provider): void
402
    {
403 5
        $definitions = $provider->getDefinitions();
404 5
        $this->setMultiple($definitions);
405 5
    }
406
407
    /**
408
     * Builds service provider by definition.
409
     *
410
     * @param mixed $provider Class name or instance of provider.
411
     *
412
     * @throws InvalidConfigException If provider argument is not valid.
413
     *
414
     * @return ServiceProviderInterface Instance of service provider.
415
     *
416
     * @psalm-suppress MoreSpecificReturnType
417
     */
418 5
    private function buildProvider($provider): ServiceProviderInterface
419
    {
420 5
        if ($this->validate && !(is_string($provider) || is_object($provider) && $provider instanceof ServiceProviderInterface)) {
421
            throw new InvalidConfigException(
422
                sprintf(
423
                    'Service provider should be a class name or an instance of %s. %s given.',
424
                    ServiceProviderInterface::class,
425
                    $this->getVariableType($provider)
426
                )
427
            );
428
        }
429
430 5
        $providerInstance = is_object($provider) ? $provider : new $provider();
431 5
        if (!$providerInstance instanceof ServiceProviderInterface) {
432
            throw new InvalidConfigException(
433
                sprintf(
434
                    'Service provider should be an instance of %s. %s given.',
435
                    ServiceProviderInterface::class,
436
                    $this->getVariableType($providerInstance)
437
                )
438
            );
439
        }
440
441
        /**
442
         * @psalm-suppress LessSpecificReturnStatement
443
         */
444 5
        return $providerInstance;
445
    }
446
447
    /**
448
     * @param mixed $variable
449
     */
450 1
    private function getVariableType($variable): string
451
    {
452 1
        if (is_object($variable)) {
453
            return get_class($variable);
454
        }
455
456 1
        return gettype($variable);
457
    }
458
}
459