Passed
Push — master ( e0690f...bab3ce )
by Alexander
02:28
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 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 11
c 1
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
45
    /**
46
     * @var bool $validate Validate definitions when set
47
     */
48
    private bool $validate;
49
50
    /**
51
     * @var object[]
52
     */
53
    private array $instances = [];
54
55
    private array $tags;
56
57
    private array $resetters = [];
58
    /** @psalm-suppress PropertyNotSetInConstructor */
59
    private DependencyResolver $dependencyResolver;
60
61
    /**
62
     * Container constructor.
63
     *
64
     * @param array $definitions Definitions to put into container.
65
     * @param array $providers Service providers to get definitions from.
66
     * lookup to when resolving dependencies. If provided the current container
67
     * is no longer queried for dependencies.
68
     *
69
     * @throws InvalidConfigException
70
     *
71
     * @psalm-suppress PropertyNotSetInConstructor
72
     */
73 97
    public function __construct(
74
        array $definitions = [],
75
        array $providers = [],
76
        array $tags = [],
77
        bool $validate = true
78
    ) {
79 97
        $this->tags = $tags;
80 97
        $this->validate = $validate;
81 97
        $this->definitions = new DefinitionStorage();
82 97
        $this->setDefaultDefinitions();
83 97
        $this->setMultiple($definitions);
84 91
        $this->addProviders($providers);
85 90
        $this->dependencyResolver = new DependencyResolver($this);
86 90
        $this->dependencyResolver = new DependencyResolver($this->get(ContainerInterface::class));
87 90
        $this->definitions->setDelegateContainer($this->get(ContainerInterface::class));
88 90
    }
89
90
    /**
91
     * Returns a value indicating whether the container has the definition of the specified name.
92
     *
93
     * @param string $id class name, interface name or alias name
94
     *
95
     * @return bool whether the container is able to provide instance of class specified.
96
     *
97
     * @see set()
98
     */
99 31
    public function has($id): bool
100
    {
101
        /** @psalm-suppress  DocblockTypeContradiction */
102 31
        if (!is_string($id)) {
0 ignored issues
show
introduced by
The condition is_string($id) is always true.
Loading history...
103
            return false;
104
        }
105
106 31
        if ($this->isTagAlias($id)) {
107 2
            $tag = substr($id, 4);
108 2
            return isset($this->tags[$tag]);
109
        }
110
111
        try {
112 29
            return $this->definitions->has($id);
113 2
        } catch (CircularReferenceException $e) {
114 2
            return true;
115
        }
116
    }
117
118
    /**
119
     * Returns an instance by either interface name or alias.
120
     *
121
     * Same instance of the class will be returned each time this method is called.
122
     *
123
     * @param string $id The interface or an alias name that was previously registered.
124
     *
125
     * @throws CircularReferenceException
126
     * @throws InvalidConfigException
127
     * @throws NotFoundException
128
     * @throws NotInstantiableException
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 90
    public function get($id)
137
    {
138
        /** @psalm-suppress TypeDoesNotContainType */
139 90
        if (!is_string($id)) {
0 ignored issues
show
introduced by
The condition is_string($id) is always true.
Loading history...
140
            throw new \InvalidArgumentException("Id must be a string, {$this->getVariableType($id)} given.");
141
        }
142
143 90
        if ($id === StateResetter::class && $this->definitions->get($id) === StateResetter::class) {
144 4
            $resetters = [];
145 4
            foreach ($this->resetters as $serviceId => $callback) {
146 4
                if (isset($this->instances[$serviceId])) {
147 4
                    $resetters[] = $callback->bindTo($this->instances[$serviceId], get_class($this->instances[$serviceId]));
148
                }
149
            }
150 4
            return new StateResetter($resetters, $this);
151
        }
152
153 90
        if (!array_key_exists($id, $this->instances)) {
154 90
            $this->instances[$id] = $this->build($id);
155
        }
156
157 90
        return $this->instances[$id];
158
    }
159
160
    /**
161
     * Sets a definition to the container. Definition may be defined multiple ways.
162
     *
163
     * @param string $id
164
     * @param mixed $definition
165
     *
166
     * @throws InvalidConfigException
167
     *
168
     * @see `DefinitionNormalizer::normalize()`
169
     */
170 97
    private function set(string $id, $definition): void
171
    {
172 97
        [$definition, $meta] = DefinitionParser::parse($definition);
173 97
        if ($this->validate) {
174 97
            $this->validateDefinition($definition, $id);
175 97
            $this->validateMeta($meta);
176
        }
177
178 97
        if (isset($meta[self::META_TAGS])) {
179 8
            if ($this->validate) {
180 8
                $this->validateTags($meta[self::META_TAGS]);
181
            }
182 8
            $this->setTags($id, $meta[self::META_TAGS]);
183
        }
184 97
        if (isset($meta[self::META_RESET])) {
185 5
            $this->setResetter($id, $meta[self::META_RESET]);
186
        }
187
188 97
        unset($this->instances[$id]);
189 97
        $this->definitions->set($id, $definition);
190 97
    }
191
192
    /**
193
     * Sets multiple definitions at once.
194
     *
195
     * @param array $config definitions indexed by their ids
196
     *
197
     * @throws InvalidConfigException
198
     */
199 97
    private function setMultiple(array $config): void
200
    {
201 97
        foreach ($config as $id => $definition) {
202 97
            if ($this->validate && !is_string($id)) {
203 1
                throw new InvalidConfigException(sprintf('Key must be a string. %s given.', $this->getVariableType($id)));
204
            }
205 97
            $this->set($id, $definition);
206
        }
207 97
    }
208
209 97
    private function setDefaultDefinitions(): void
210
    {
211 97
        $this->setMultiple([
212 97
            ContainerInterface::class => $this,
213
            StateResetter::class => StateResetter::class,
214
        ]);
215 97
    }
216
217
    /**
218
     * @param mixed $definition
219
     *
220
     * @throws InvalidConfigException
221
     */
222 97
    private function validateDefinition($definition, ?string $id = null): void
223
    {
224 97
        if (is_array($definition) && isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) {
225 39
            $class = $definition['class'];
226 39
            $constructorArguments = $definition['__construct()'];
227 39
            $methodsAndProperties = $definition['methodsAndProperties'];
228 39
            $definition = array_merge(
229 39
                $class === null ? [] : [ArrayDefinition::CLASS_NAME => $class],
230 39
                [ArrayDefinition::CONSTRUCTOR => $constructorArguments],
231
                $methodsAndProperties,
232
            );
233
        }
234
235 97
        if ($definition instanceof ExtensibleService) {
236
            throw new InvalidConfigException('Invalid definition. ExtensibleService is only allowed in provider extensions.');
237
        }
238
239 97
        DefinitionValidator::validate($definition, $id);
240 97
    }
241
242
    /**
243
     * @throws InvalidConfigException
244
     */
245 97
    private function validateMeta(array $meta): void
246
    {
247 97
        foreach ($meta as $key => $_value) {
248 16
            if (!in_array($key, self::ALLOWED_META, true)) {
249 3
                throw new InvalidConfigException(
250 3
                    sprintf(
251 3
                        'Invalid definition: metadata "%s" is not allowed. Did you mean "%s()" or "$%s"?',
252
                        $key,
253
                        $key,
254
                        $key,
255
                    )
256
                );
257
            }
258
        }
259 97
    }
260
261 8
    private function validateTags(array $tags): void
262
    {
263 8
        foreach ($tags as $tag) {
264 8
            if (!is_string($tag)) {
265
                throw new InvalidConfigException('Invalid tag. Expected a string, got ' . var_export($tag, true) . '.');
266
            }
267
        }
268 8
    }
269
270 8
    private function setTags(string $id, array $tags): void
271
    {
272 8
        foreach ($tags as $tag) {
273 8
            if (!isset($this->tags[$tag]) || !in_array($id, $this->tags[$tag], true)) {
274 8
                $this->tags[$tag][] = $id;
275
            }
276
        }
277 8
    }
278
279 4
    private function setResetter(string $id, Closure $resetter): void
280
    {
281 4
        $this->resetters[$id] = $resetter;
282 4
    }
283
284
    /**
285
     * Creates new instance by either interface name or alias.
286
     *
287
     * @param string $id The interface or an alias name that was previously registered.
288
     *
289
     * @throws CircularReferenceException
290
     * @throws InvalidConfigException
291
     * @throws NotFoundException
292
     *
293
     * @return mixed|object New built instance of the specified class.
294
     *
295
     * @internal
296
     */
297 90
    private function build(string $id)
298
    {
299 90
        if ($this->isTagAlias($id)) {
300 9
            return $this->getTaggedServices($id);
301
        }
302
303 90
        if (isset($this->building[$id])) {
304 5
            if ($id === ContainerInterface::class) {
305 1
                return $this;
306
            }
307 4
            throw new CircularReferenceException(sprintf(
308 4
                'Circular reference to "%s" detected while building: %s.',
309
                $id,
310 4
                implode(', ', array_keys($this->building))
311
            ));
312
        }
313
314 90
        $this->building[$id] = 1;
315
        try {
316 90
            $object = $this->buildInternal($id);
317 90
        } finally {
318 90
            unset($this->building[$id]);
319
        }
320
321 90
        return $object;
322
    }
323
324 90
    private function isTagAlias(string $id): bool
325
    {
326 90
        return strpos($id, 'tag@') === 0;
327
    }
328
329 9
    private function getTaggedServices(string $tagAlias): array
330
    {
331 9
        $tag = substr($tagAlias, 4);
332 9
        $services = [];
333 9
        if (isset($this->tags[$tag])) {
334 8
            foreach ($this->tags[$tag] as $service) {
335 8
                $services[] = $this->get($service);
336
            }
337
        }
338
339 9
        return $services;
340
    }
341
342
    /**
343
     * @param string $id
344
     *
345
     * @throws InvalidConfigException
346
     * @throws NotFoundException
347
     *
348
     * @return mixed|object
349
     */
350 90
    private function buildInternal(string $id)
351
    {
352 90
        if ($this->definitions->has($id)) {
353 90
            $definition = DefinitionNormalizer::normalize($this->definitions->get($id), $id);
354
355 90
            return $definition->resolve($this->dependencyResolver);
356
        }
357
358 4
        throw new NotFoundException($id);
359
    }
360
361 91
    private function addProviders(array $providers): void
362
    {
363 91
        $extensions = [];
364 91
        foreach ($providers as $provider) {
365 5
            $providerInstance = $this->buildProvider($provider);
366 5
            $extensions[] = $providerInstance->getExtensions();
367 5
            $this->addProviderDefinitions($providerInstance);
368
        }
369
370 91
        foreach ($extensions as $providerExtensions) {
371 5
            foreach ($providerExtensions as $id => $extension) {
372 4
                if (!$this->definitions->has($id)) {
373 1
                    throw new InvalidConfigException("Extended service \"$id\" doesn't exist.");
374
                }
375
376 4
                $definition = $this->definitions->get($id);
377 4
                if (!$definition instanceof ExtensibleService) {
378 4
                    $definition = new ExtensibleService($definition);
379 4
                    $this->definitions->set($id, $definition);
380
                }
381
382 4
                $definition->addExtension($extension);
383
            }
384
        }
385 90
    }
386
387
    /**
388
     * Adds service provider definitions to the container.
389
     *
390
     * @param object $provider
391
     *
392
     * @throws InvalidConfigException
393
     * @throws NotInstantiableException
394
     *
395
     * @see ServiceProviderInterface
396
     */
397 5
    private function addProviderDefinitions($provider): void
398
    {
399 5
        $definitions = $provider->getDefinitions();
400 5
        $this->setMultiple($definitions);
401 5
    }
402
403
    /**
404
     * Builds service provider by definition.
405
     *
406
     * @param mixed $provider Class name or instance of provider.
407
     *
408
     * @throws InvalidConfigException If provider argument is not valid.
409
     *
410
     * @return ServiceProviderInterface Instance of service provider.
411
     *
412
     * @psalm-suppress MoreSpecificReturnType
413
     */
414 5
    private function buildProvider($provider): ServiceProviderInterface
415
    {
416 5
        if ($this->validate && !(is_string($provider) || is_object($provider) && $provider instanceof ServiceProviderInterface)) {
417
            throw new InvalidConfigException(
418
                sprintf(
419
                    'Service provider should be a class name or an instance of %s. %s given.',
420
                    ServiceProviderInterface::class,
421
                    $this->getVariableType($provider)
422
                )
423
            );
424
        }
425
426 5
        $providerInstance = is_object($provider) ? $provider : new $provider();
427 5
        if (!$providerInstance instanceof ServiceProviderInterface) {
428
            throw new InvalidConfigException(
429
                sprintf(
430
                    'Service provider should be an instance of %s. %s given.',
431
                    ServiceProviderInterface::class,
432
                    $this->getVariableType($providerInstance)
433
                )
434
            );
435
        }
436
437
        /**
438
         * @psalm-suppress LessSpecificReturnStatement
439
         */
440 5
        return $providerInstance;
441
    }
442
443
    /**
444
     * @param mixed $variable
445
     */
446 1
    private function getVariableType($variable): string
447
    {
448 1
        return is_object($variable) ? get_class($variable) : gettype($variable);
449
    }
450
}
451