Passed
Push — upgrade-psr ( 8f6abe...9a5b54 )
by Dmitriy
07:11 queued 04:47
created

Container::buildInternal()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 4
c 2
b 0
f 0
nc 2
nop 1
dl 0
loc 9
ccs 5
cts 5
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\ContainerInterface;
9
use Yiisoft\Definitions\Infrastructure\DefinitionValidator;
10
use Yiisoft\Di\Contracts\ServiceProviderInterface;
11
use Yiisoft\Definitions\ArrayDefinition;
12
use Yiisoft\Definitions\Exception\CircularReferenceException;
13
use Yiisoft\Definitions\Exception\InvalidConfigException;
14
use Yiisoft\Definitions\Exception\NotFoundException;
15
use Yiisoft\Definitions\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 CompositeContainer $delegates;
56
57
    private array $tags;
58
59
    private array $resetters = [];
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
     * @param array $tags
69
     * @param bool $validate If definitions should be validated immediately.
70
     * @param array $delegates Container delegates. Each delegate must is a callable in format
71
     * "function (ContainerInterface $container): ContainerInterface". The container instance returned is used
72
     * in case a service can not be found in primary container.
73
     *
74
     * @throws InvalidConfigException
75
     *
76
     * @psalm-suppress PropertyNotSetInConstructor
77
     */
78 95
    public function __construct(
79
        array $definitions = [],
80
        array $providers = [],
81
        array $tags = [],
82
        bool $validate = true,
83
        array $delegates = []
84
    ) {
85 95
        $this->tags = $tags;
86 95
        $this->validate = $validate;
87 95
        $this->definitions = new DefinitionStorage();
88 95
        $this->setDefaultDefinitions();
89 95
        $this->setMultiple($definitions);
90 89
        $this->addProviders($providers);
91 88
        $this->setDelegates($delegates);
92 88
    }
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 set()
102
     */
103 26
    public function has(string $id): bool
104
    {
105 26
        if ($this->isTagAlias($id)) {
106 2
            $tag = substr($id, 4);
107 2
            return isset($this->tags[$tag]);
108
        }
109
110
        try {
111 24
            return $this->definitions->has($id);
112 2
        } catch (CircularReferenceException $e) {
113 2
            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 NotFoundException
127
     * @throws NotInstantiableException
128
     *
129
     * @return mixed|object An instance of the requested interface.
130
     *
131
     * @psalm-template T
132
     * @psalm-param string|class-string<T> $id
133
     * @psalm-return ($id is class-string ? T : mixed)
134
     */
135 82
    public function get(string $id)
136
    {
137
        /** @psalm-suppress TypeDoesNotContainType */
138 82
        if (!is_string($id)) {
0 ignored issues
show
introduced by
The condition is_string($id) is always true.
Loading history...
139
            throw new \InvalidArgumentException("Id must be a string, {$this->getVariableType($id)} given.");
140
        }
141
142 82
        if (!array_key_exists($id, $this->instances)) {
143
            try {
144 82
                $this->instances[$id] = $this->build($id);
145 14
            } catch (NotFoundException $e) {
146 5
                if (!$this->delegates->has($id)) {
147 4
                    throw $e;
148
                }
149
150 1
                return $this->delegates->get($id);
151
            }
152
        }
153
154 76
        if ($id === StateResetter::class && $this->definitions->get($id) === StateResetter::class) {
155 4
            $resetters = [];
156 4
            foreach ($this->resetters as $serviceId => $callback) {
157 4
                if (isset($this->instances[$serviceId])) {
158 4
                    $resetters[$serviceId] = $callback;
159
                }
160
            }
161 4
            if ($this->delegates->has(StateResetter::class)) {
162
                $resetters[] = $this->delegates->get(StateResetter::class);
163
            }
164 4
            $this->instances[$id]->setResetters($resetters);
165
        }
166
167 76
        return $this->instances[$id];
168
    }
169
170
    /**
171
     * Sets a definition to the container. Definition may be defined multiple ways.
172
     *
173
     * @param string $id
174
     * @param mixed $definition
175
     *
176
     * @throws InvalidConfigException
177
     *
178
     * @see `DefinitionNormalizer::normalize()`
179
     */
180 95
    private function set(string $id, $definition): void
181
    {
182 95
        [$definition, $meta] = DefinitionParser::parse($definition);
183 95
        if ($this->validate) {
184 95
            $this->validateDefinition($definition, $id);
185 95
            $this->validateMeta($meta);
186
        }
187
188 95
        if (isset($meta[self::META_TAGS])) {
189 8
            if ($this->validate) {
190 8
                $this->validateTags($meta[self::META_TAGS]);
191
            }
192 8
            $this->setTags($id, $meta[self::META_TAGS]);
193
        }
194 95
        if (isset($meta[self::META_RESET])) {
195 5
            $this->setResetter($id, $meta[self::META_RESET]);
196
        }
197
198 95
        unset($this->instances[$id]);
199 95
        $this->definitions->set($id, $definition);
200 95
    }
201
202
    /**
203
     * Sets multiple definitions at once.
204
     *
205
     * @param array $config definitions indexed by their ids
206
     *
207
     * @throws InvalidConfigException
208
     */
209 95
    private function setMultiple(array $config): void
210
    {
211 95
        foreach ($config as $id => $definition) {
212 95
            if ($this->validate && !is_string($id)) {
213 1
                throw new InvalidConfigException(sprintf('Key must be a string. %s given.', $this->getVariableType($id)));
214
            }
215 95
            $this->set($id, $definition);
216
        }
217 95
    }
218
219 95
    private function setDefaultDefinitions(): void
220
    {
221 95
        $this->setMultiple([
222 95
            ContainerInterface::class => $this,
223
            StateResetter::class => StateResetter::class,
224
        ]);
225 95
    }
226
227
    /**
228
     * Set container delegates.
229
     *
230
     * Each delegate must is a callable in format "function (ContainerInterface $container): ContainerInterface".
231
     * The container instance returned is used in case a service can not be found in primary container.
232
     *
233
     * @param array $delegates
234
     *
235
     * @throws InvalidConfigException
236
     */
237 88
    private function setDelegates(array $delegates): void
238
    {
239 88
        $this->delegates = new CompositeContainer();
240 88
        foreach ($delegates as $delegate) {
241 1
            if (!$delegate instanceof Closure) {
242
                throw new InvalidConfigException(
243
                    'Delegate must be callable in format "function (ContainerInterface $container): ContainerInterface".'
244
                );
245
            }
246
247 1
            $delegate = $delegate($this);
248
249 1
            if (!$delegate instanceof ContainerInterface) {
250
                throw new InvalidConfigException(
251
                    'Delegate callable must return an object that implements ContainerInterface.'
252
                );
253
            }
254
255 1
            $this->delegates->attach($delegate);
256
        }
257 88
        $this->definitions->setDelegateContainer($this->delegates);
258 88
    }
259
260
    /**
261
     * @param mixed $definition
262
     *
263
     * @throws InvalidConfigException
264
     */
265 95
    private function validateDefinition($definition, ?string $id = null): void
266
    {
267 95
        if (is_array($definition) && isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) {
268 36
            $class = $definition['class'];
269 36
            $constructorArguments = $definition['__construct()'];
270 36
            $methodsAndProperties = $definition['methodsAndProperties'];
271 36
            $definition = array_merge(
272 36
                $class === null ? [] : [ArrayDefinition::CLASS_NAME => $class],
273 36
                [ArrayDefinition::CONSTRUCTOR => $constructorArguments],
274
                $methodsAndProperties,
275
            );
276
        }
277
278 95
        if ($definition instanceof ExtensibleService) {
279
            throw new InvalidConfigException('Invalid definition. ExtensibleService is only allowed in provider extensions.');
280
        }
281
282 95
        DefinitionValidator::validate($definition, $id);
283 95
    }
284
285
    /**
286
     * @throws InvalidConfigException
287
     */
288 95
    private function validateMeta(array $meta): void
289
    {
290 95
        foreach ($meta as $key => $_value) {
291 16
            if (!in_array($key, self::ALLOWED_META, true)) {
292 3
                throw new InvalidConfigException(
293 3
                    sprintf(
294 3
                        'Invalid definition: metadata "%s" is not allowed. Did you mean "%s()" or "$%s"?',
295
                        $key,
296
                        $key,
297
                        $key,
298
                    )
299
                );
300
            }
301
        }
302 95
    }
303
304 8
    private function validateTags(array $tags): void
305
    {
306 8
        foreach ($tags as $tag) {
307 8
            if (!is_string($tag)) {
308
                throw new InvalidConfigException('Invalid tag. Expected a string, got ' . var_export($tag, true) . '.');
309
            }
310
        }
311 8
    }
312
313 8
    private function setTags(string $id, array $tags): void
314
    {
315 8
        foreach ($tags as $tag) {
316 8
            if (!isset($this->tags[$tag]) || !in_array($id, $this->tags[$tag], true)) {
317 8
                $this->tags[$tag][] = $id;
318
            }
319
        }
320 8
    }
321
322 4
    private function setResetter(string $id, Closure $resetter): void
323
    {
324 4
        $this->resetters[$id] = $resetter;
325 4
    }
326
327
    /**
328
     * Creates new instance by either interface name or alias.
329
     *
330
     * @param string $id The interface or an alias name that was previously registered.
331
     *
332
     * @throws CircularReferenceException
333
     * @throws InvalidConfigException
334
     * @throws NotFoundException
335
     *
336
     * @return mixed|object New built instance of the specified class.
337
     *
338
     * @internal
339
     */
340 82
    private function build(string $id)
341
    {
342 82
        if ($this->isTagAlias($id)) {
343 9
            return $this->getTaggedServices($id);
344
        }
345
346 81
        if (isset($this->building[$id])) {
347 75
            if ($id === ContainerInterface::class) {
348 75
                return $this;
349
            }
350 4
            throw new CircularReferenceException(sprintf(
351 4
                'Circular reference to "%s" detected while building: %s.',
352
                $id,
353 4
                implode(', ', array_keys($this->building))
354
            ));
355
        }
356
357 81
        $this->building[$id] = 1;
358
        try {
359 81
            $object = $this->buildInternal($id);
360 75
        } finally {
361 81
            unset($this->building[$id]);
362
        }
363
364 75
        return $object;
365
    }
366
367 87
    private function isTagAlias(string $id): bool
368
    {
369 87
        return strpos($id, 'tag@') === 0;
370
    }
371
372 9
    private function getTaggedServices(string $tagAlias): array
373
    {
374 9
        $tag = substr($tagAlias, 4);
375 9
        $services = [];
376 9
        if (isset($this->tags[$tag])) {
377 8
            foreach ($this->tags[$tag] as $service) {
378 8
                $services[] = $this->get($service);
379
            }
380
        }
381
382 9
        return $services;
383
    }
384
385
    /**
386
     * @param string $id
387
     *
388
     * @throws InvalidConfigException
389
     * @throws NotFoundException
390
     *
391
     * @return mixed|object
392
     */
393 81
    private function buildInternal(string $id)
394
    {
395 81
        if ($this->definitions->has($id)) {
396 75
            $definition = DefinitionNormalizer::normalize($this->definitions->get($id), $id);
397
398 75
            return $definition->resolve($this->get(ContainerInterface::class));
399
        }
400
401 5
        throw new NotFoundException($id, $this->definitions->getBuildStack());
402
    }
403
404 89
    private function addProviders(array $providers): void
405
    {
406 89
        $extensions = [];
407 89
        foreach ($providers as $provider) {
408 5
            $providerInstance = $this->buildProvider($provider);
409 5
            $extensions[] = $providerInstance->getExtensions();
410 5
            $this->addProviderDefinitions($providerInstance);
411
        }
412
413 89
        foreach ($extensions as $providerExtensions) {
414 5
            foreach ($providerExtensions as $id => $extension) {
415 4
                if (!$this->definitions->has($id)) {
416 1
                    throw new InvalidConfigException("Extended service \"$id\" doesn't exist.");
417
                }
418
419 4
                $definition = $this->definitions->get($id);
420 4
                if (!$definition instanceof ExtensibleService) {
421 4
                    $definition = new ExtensibleService($definition);
422 4
                    $this->definitions->set($id, $definition);
423
                }
424
425 4
                $definition->addExtension($extension);
426
            }
427
        }
428 88
    }
429
430
    /**
431
     * Adds service provider definitions to the container.
432
     *
433
     * @param object $provider
434
     *
435
     * @throws InvalidConfigException
436
     * @throws NotInstantiableException
437
     *
438
     * @see ServiceProviderInterface
439
     */
440 5
    private function addProviderDefinitions($provider): void
441
    {
442 5
        $definitions = $provider->getDefinitions();
443 5
        $this->setMultiple($definitions);
444 5
    }
445
446
    /**
447
     * Builds service provider by definition.
448
     *
449
     * @param mixed $provider Class name or instance of provider.
450
     *
451
     * @throws InvalidConfigException If provider argument is not valid.
452
     *
453
     * @return ServiceProviderInterface Instance of service provider.
454
     *
455
     * @psalm-suppress MoreSpecificReturnType
456
     */
457 5
    private function buildProvider($provider): ServiceProviderInterface
458
    {
459 5
        if ($this->validate && !(is_string($provider) || is_object($provider) && $provider instanceof ServiceProviderInterface)) {
460
            throw new InvalidConfigException(
461
                sprintf(
462
                    'Service provider should be a class name or an instance of %s. %s given.',
463
                    ServiceProviderInterface::class,
464
                    $this->getVariableType($provider)
465
                )
466
            );
467
        }
468
469 5
        $providerInstance = is_object($provider) ? $provider : new $provider();
470 5
        if (!$providerInstance instanceof ServiceProviderInterface) {
471
            throw new InvalidConfigException(
472
                sprintf(
473
                    'Service provider should be an instance of %s. %s given.',
474
                    ServiceProviderInterface::class,
475
                    $this->getVariableType($providerInstance)
476
                )
477
            );
478
        }
479
480
        /**
481
         * @psalm-suppress LessSpecificReturnStatement
482
         */
483 5
        return $providerInstance;
484
    }
485
486
    /**
487
     * @param mixed $variable
488
     */
489 1
    private function getVariableType($variable): string
490
    {
491 1
        return is_object($variable) ? get_class($variable) : gettype($variable);
492
    }
493
}
494