Passed
Push — master ( 4b52f9...0619ac )
by Alexander
02:48
created

Container::buildProvider()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 27
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 16.6682

Importance

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