Passed
Push — lazy-suggestion ( 4afec9 )
by Sergei
13:01
created

Container::decorateLazy()   B

Complexity

Conditions 7
Paths 24

Size

Total Lines 28
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 9.1008

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 7
eloc 17
c 3
b 0
f 0
nc 24
nop 2
dl 0
loc 28
ccs 13
cts 20
cp 0.65
crap 9.1008
rs 8.8333
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Di;
6
7
use Closure;
8
use Psr\Container\ContainerExceptionInterface;
9
use Psr\Container\ContainerInterface;
10
use Psr\Container\NotFoundExceptionInterface;
11
use Throwable;
12
use Yiisoft\Definitions\ArrayDefinition;
13
use Yiisoft\Definitions\Contract\DefinitionInterface;
14
use Yiisoft\Definitions\DefinitionStorage;
15
use Yiisoft\Definitions\Exception\CircularReferenceException;
16
use Yiisoft\Definitions\Exception\InvalidConfigException;
17
use Yiisoft\Definitions\Exception\NotInstantiableException;
18
use Yiisoft\Definitions\Helpers\DefinitionValidator;
19
use Yiisoft\Definitions\LazyDefinition;
20
use Yiisoft\Di\Helpers\DefinitionNormalizer;
21
use Yiisoft\Di\Helpers\DefinitionParser;
22
use Yiisoft\Di\Reference\TagReference;
23
24
use function array_key_exists;
25
use function array_keys;
26
use function implode;
27
use function in_array;
28
use function is_array;
29
use function is_callable;
30
use function is_object;
31
use function is_string;
32
33
/**
34
 * Container implements a [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) container.
35
 */
36
final class Container implements ContainerInterface
37
{
38
    private const META_TAGS = 'tags';
39
    private const META_RESET = 'reset';
40
    private const META_LAZY = 'lazy';
41
    private const ALLOWED_META = [self::META_TAGS, self::META_RESET, self::META_LAZY];
42
43
    /**
44
     * @var DefinitionStorage Storage of object definitions.
45
     */
46
    private DefinitionStorage $definitions;
47
48
    /**
49
     * @var array Used to collect IDs of objects instantiated during build
50
     * to detect circular references.
51
     */
52
    private array $building = [];
53
54
    /**
55
     * @var bool $validate If definitions should be validated.
56
     */
57
    private bool $validate;
58
59
    private array $instances = [];
60
61
    private CompositeContainer $delegates;
62
63
    /**
64
     * @var array Tagged service IDs. The structure is `['tagID' => ['service1', 'service2']]`.
65
     * @psalm-var array<string, list<string>>
66
     */
67
    private array $tags;
68
69
    /**
70
     * @var Closure[]
71
     */
72
    private array $resetters = [];
73
    private bool $useResettersFromMeta = true;
74
75
    /**
76
     * @param ContainerConfigInterface $config Container configuration.
77
     *
78
     * @throws InvalidConfigException If configuration is not valid.
79
     */
80 145
    public function __construct(ContainerConfigInterface $config)
81
    {
82 145
        $this->definitions = new DefinitionStorage(
83 145
            [
84 145
                ContainerInterface::class => $this,
85 145
                StateResetter::class => StateResetter::class,
86 145
            ],
87 145
            $config->useStrictMode()
88 145
        );
89 145
        $this->validate = $config->shouldValidate();
90 145
        $this->setTags($config->getTags());
91 142
        $this->addDefinitions($config->getDefinitions());
92 132
        $this->addProviders($config->getProviders());
93 126
        $this->setDelegates($config->getDelegates());
94
    }
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 addDefinition()
104
     */
105 47
    public function has(string $id): bool
106
    {
107 47
        if (TagReference::isTagAlias($id)) {
108 3
            $tag = TagReference::extractTagFromAlias($id);
109 3
            return isset($this->tags[$tag]);
110
        }
111
112
        try {
113 44
            return $this->definitions->has($id);
114 3
        } catch (CircularReferenceException) {
115 3
            return true;
116
        }
117
    }
118
119
    /**
120
     * Returns an instance by either interface name or alias.
121
     *
122
     * Same instance of the class will be returned each time this method is called.
123
     *
124
     * @param string $id The interface or an alias name that was previously registered.
125
     *
126
     * @throws CircularReferenceException
127
     * @throws InvalidConfigException
128
     * @throws NotFoundExceptionInterface
129
     * @throws NotInstantiableException
130
     * @throws BuildingException
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 126
    public function get(string $id)
139
    {
140 126
        if (!array_key_exists($id, $this->instances)) {
141
            try {
142
                try {
143 126
                    $this->instances[$id] = $this->build($id);
144 21
                } catch (NotFoundExceptionInterface $e) {
145 12
                    if (!$this->delegates->has($id)) {
146 9
                        throw $e;
147
                    }
148
149
                    /** @psalm-suppress MixedReturnStatement */
150 126
                    return $this->delegates->get($id);
151
                }
152 18
            } catch (Throwable $e) {
153 18
                if ($e instanceof ContainerExceptionInterface && !$e instanceof InvalidConfigException) {
154 16
                    throw $e;
155
                }
156 2
                throw new BuildingException($id, $e, $this->definitions->getBuildStack(), $e);
157
            }
158
        }
159
160 126
        if ($id === StateResetter::class) {
161 10
            $delegatesResetter = null;
162 10
            if ($this->delegates->has(StateResetter::class)) {
163 2
                $delegatesResetter = $this->delegates->get(StateResetter::class);
164
            }
165
166
            /** @var StateResetter $mainResetter */
167 10
            $mainResetter = $this->instances[$id];
168
169 10
            if ($this->useResettersFromMeta) {
170
                /** @var StateResetter[] $resetters */
171 7
                $resetters = [];
172 7
                foreach ($this->resetters as $serviceId => $callback) {
173 7
                    if (isset($this->instances[$serviceId])) {
174 7
                        $resetters[$serviceId] = $callback;
175
                    }
176
                }
177 7
                if ($delegatesResetter !== null) {
178 1
                    $resetters[] = $delegatesResetter;
179
                }
180 7
                $mainResetter->setResetters($resetters);
181 5
            } elseif ($delegatesResetter !== null) {
182 1
                $resetter = new StateResetter($this->get(ContainerInterface::class));
183 1
                $resetter->setResetters([$mainResetter, $delegatesResetter]);
184
185 1
                return $resetter;
186
            }
187
        }
188
189
        /** @psalm-suppress MixedReturnStatement */
190 126
        return $this->instances[$id];
191
    }
192
193
    /**
194
     * Sets a definition to the container. Definition may be defined multiple ways.
195
     *
196
     * @param string $id ID to set definition for.
197
     * @param mixed $definition Definition to set.
198
     *
199
     * @throws InvalidConfigException
200
     *
201
     * @see DefinitionNormalizer::normalize()
202
     */
203 117
    private function addDefinition(string $id, mixed $definition): void
204
    {
205 117
        [$definition, $meta] = DefinitionParser::parse($definition);
206 117
        if ($this->validate) {
207 117
            $this->validateDefinition($definition, $id);
208 114
            $this->validateMeta($meta);
209
        }
210
        /**
211
         * @psalm-var array{reset?:Closure,lazy?:bool,tags?:string[]} $meta
212
         */
213
214 108
        if (isset($meta[self::META_TAGS])) {
215 9
            $this->setDefinitionTags($id, $meta[self::META_TAGS]);
216
        }
217 108
        if (isset($meta[self::META_RESET])) {
218 7
            $this->setDefinitionResetter($id, $meta[self::META_RESET]);
219
        }
220 108
        if (isset($meta[self::META_LAZY]) && $meta[self::META_LAZY] === true) {
221 6
            $definition = $this->decorateLazy($id, $definition);
222
        }
223
224 108
        unset($this->instances[$id]);
225 108
        $this->addDefinitionToStorage($id, $definition);
226
    }
227
228
    /**
229
     * Sets multiple definitions at once.
230
     *
231
     * @param array $config Definitions indexed by their IDs.
232
     *
233
     * @throws InvalidConfigException
234
     */
235 142
    private function addDefinitions(array $config): void
236
    {
237
        /** @var mixed $definition */
238 142
        foreach ($config as $id => $definition) {
239 116
            if ($this->validate && !is_string($id)) {
240 1
                throw new InvalidConfigException(
241 1
                    sprintf(
242 1
                        'Key must be a string. %s given.',
243 1
                        get_debug_type($id)
244 1
                    )
245 1
                );
246
            }
247
            /** @var string $id */
248
249 115
            $this->addDefinition($id, $definition);
250
        }
251
    }
252
253
    /**
254
     * Set container delegates.
255
     *
256
     * Each delegate must is a callable in format "function (ContainerInterface $container): ContainerInterface".
257
     * The container instance returned is used in case a service can not be found in primary container.
258
     *
259
     * @throws InvalidConfigException
260
     */
261 126
    private function setDelegates(array $delegates): void
262
    {
263 126
        $this->delegates = new CompositeContainer();
264 126
        $container = $this->get(ContainerInterface::class);
265
266 126
        foreach ($delegates as $delegate) {
267 6
            if (!$delegate instanceof Closure) {
268 1
                throw new InvalidConfigException(
269 1
                    'Delegate must be callable in format "function (ContainerInterface $container): ContainerInterface".'
270 1
                );
271
            }
272
273
            /** @var ContainerInterface */
274 5
            $delegate = $delegate($container);
275
276 5
            if (!$delegate instanceof ContainerInterface) {
277 1
                throw new InvalidConfigException(
278 1
                    'Delegate callable must return an object that implements ContainerInterface.'
279 1
                );
280
            }
281
282 4
            $this->delegates->attach($delegate);
283
        }
284 124
        $this->definitions->setDelegateContainer($this->delegates);
285
    }
286
287
    /**
288
     * @param mixed $definition Definition to validate.
289
     * @param string|null $id ID of the definition to validate.
290
     *
291
     * @throws InvalidConfigException
292
     */
293 117
    private function validateDefinition(mixed $definition, ?string $id = null): void
294
    {
295 117
        if (is_array($definition) && isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) {
296
            /** @var mixed $class */
297 51
            $class = $definition['class'];
298
299
            /** @var mixed $constructorArguments */
300 51
            $constructorArguments = $definition['__construct()'];
301
302
            /**
303
             * @var array $methodsAndProperties Is always array for prepared array definition data.
304
             *
305
             * @see DefinitionParser::parse()
306
             */
307 51
            $methodsAndProperties = $definition['methodsAndProperties'];
308
309 51
            $definition = array_merge(
310 51
                $class === null ? [] : [ArrayDefinition::CLASS_NAME => $class],
311 51
                [ArrayDefinition::CONSTRUCTOR => $constructorArguments],
312
                // extract only value from parsed definition method
313 51
                array_map(fn (array $data): mixed => $data[2], $methodsAndProperties),
314 51
            );
315
        }
316
317 117
        if ($definition instanceof ExtensibleService) {
318 1
            throw new InvalidConfigException(
319 1
                'Invalid definition. ExtensibleService is only allowed in provider extensions.'
320 1
            );
321
        }
322
323 116
        DefinitionValidator::validate($definition, $id);
324
    }
325
326
    /**
327
     * @throws InvalidConfigException
328
     */
329 114
    private function validateMeta(array $meta): void
330
    {
331
        /** @var mixed $value */
332 114
        foreach ($meta as $key => $value) {
333 28
            if (!in_array($key, self::ALLOWED_META, true)) {
334 3
                throw new InvalidConfigException(
335 3
                    sprintf(
336 3
                        'Invalid definition: metadata "%s" is not allowed. Did you mean "%s()" or "$%s"?',
337 3
                        $key,
338 3
                        $key,
339 3
                        $key,
340 3
                    )
341 3
                );
342
            }
343
344 26
            if ($key === self::META_TAGS) {
345 12
                $this->validateDefinitionTags($value);
346
            }
347
348 24
            if ($key === self::META_RESET) {
349 8
                $this->validateDefinitionReset($value);
350
            }
351
        }
352
    }
353
354
    /**
355
     * @throws InvalidConfigException
356
     */
357 12
    private function validateDefinitionTags(mixed $tags): void
358
    {
359 12
        if (!is_array($tags)) {
360 1
            throw new InvalidConfigException(
361 1
                sprintf(
362 1
                    'Invalid definition: tags should be array of strings, %s given.',
363 1
                    get_debug_type($tags)
364 1
                )
365 1
            );
366
        }
367
368 11
        foreach ($tags as $tag) {
369 11
            if (!is_string($tag)) {
370 1
                throw new InvalidConfigException('Invalid tag. Expected a string, got ' . var_export($tag, true) . '.');
371
            }
372
        }
373
    }
374
375
    /**
376
     * @throws InvalidConfigException
377
     */
378 8
    private function validateDefinitionReset(mixed $reset): void
379
    {
380 8
        if (!$reset instanceof Closure) {
381 1
            throw new InvalidConfigException(
382 1
                sprintf(
383 1
                    'Invalid definition: "reset" should be closure, %s given.',
384 1
                    get_debug_type($reset)
385 1
                )
386 1
            );
387
        }
388
    }
389
390
    /**
391
     * @throws InvalidConfigException
392
     */
393 145
    private function setTags(array $tags): void
394
    {
395 145
        if ($this->validate) {
396 145
            foreach ($tags as $tag => $services) {
397 5
                if (!is_string($tag)) {
398 1
                    throw new InvalidConfigException(
399 1
                        sprintf(
400 1
                            'Invalid tags configuration: tag should be string, %s given.',
401 1
                            $tag
402 1
                        )
403 1
                    );
404
                }
405 4
                if (!is_array($services)) {
406 1
                    throw new InvalidConfigException(
407 1
                        sprintf(
408 1
                            'Invalid tags configuration: tag should contain array of service IDs, %s given.',
409 1
                            get_debug_type($services)
410 1
                        )
411 1
                    );
412
                }
413
                /** @var mixed $service */
414 3
                foreach ($services as $service) {
415 3
                    if (!is_string($service)) {
416 1
                        throw new InvalidConfigException(
417 1
                            sprintf(
418 1
                                'Invalid tags configuration: service should be defined as class string, %s given.',
419 1
                                get_debug_type($service)
420 1
                            )
421 1
                        );
422
                    }
423
                }
424
            }
425
        }
426
        /** @psalm-var array<string, list<string>> $tags */
427
428 142
        $this->tags = $tags;
429
    }
430
431
    /**
432
     * @psalm-param string[] $tags
433
     */
434 9
    private function setDefinitionTags(string $id, array $tags): void
435
    {
436 9
        foreach ($tags as $tag) {
437 9
            if (!isset($this->tags[$tag]) || !in_array($id, $this->tags[$tag], true)) {
438 9
                $this->tags[$tag][] = $id;
439
            }
440
        }
441
    }
442
443 7
    private function setDefinitionResetter(string $id, Closure $resetter): void
444
    {
445 7
        $this->resetters[$id] = $resetter;
446
    }
447
448
    /**
449
     * Add definition to storage.
450
     *
451
     * @param string $id ID to set definition for.
452
     * @param mixed|object $definition Definition to set.
453
     *
454
     * @see $definitions
455
     */
456 108
    private function addDefinitionToStorage(string $id, $definition): void
457
    {
458 108
        $this->definitions->set($id, $definition);
459
460 108
        if ($id === StateResetter::class) {
461 5
            $this->useResettersFromMeta = false;
462
        }
463
    }
464
465
    /**
466
     * Creates new instance by either interface name or alias.
467
     *
468
     * @param string $id The interface or an alias name that was previously registered.
469
     *
470
     * @throws InvalidConfigException
471
     * @throws NotFoundExceptionInterface
472
     * @throws CircularReferenceException
473
     *
474
     * @return mixed|object New built instance of the specified class.
475
     *
476
     * @internal
477
     */
478 126
    private function build(string $id)
479
    {
480 126
        if (TagReference::isTagAlias($id)) {
481 10
            return $this->getTaggedServices($id);
482
        }
483
484 126
        if (isset($this->building[$id])) {
485 126
            if ($id === ContainerInterface::class) {
486 126
                return $this;
487
            }
488 4
            throw new CircularReferenceException(
489 4
                sprintf(
490 4
                    'Circular reference to "%s" detected while building: %s.',
491 4
                    $id,
492 4
                    implode(', ', array_keys($this->building))
493 4
                )
494 4
            );
495
        }
496
497 126
        $this->building[$id] = 1;
498
        try {
499
            /** @var mixed $object */
500 126
            $object = $this->buildInternal($id);
501
        } finally {
502 126
            unset($this->building[$id]);
503
        }
504
505 126
        return $object;
506
    }
507
508 10
    private function getTaggedServices(string $tagAlias): array
509
    {
510 10
        $tag = TagReference::extractTagFromAlias($tagAlias);
511 10
        $services = [];
512 10
        if (isset($this->tags[$tag])) {
513 9
            foreach ($this->tags[$tag] as $service) {
514
                /** @var mixed */
515 9
                $services[] = $this->get($service);
516
            }
517
        }
518
519 10
        return $services;
520
    }
521
522
    /**
523
     * @throws NotFoundExceptionInterface
524
     * @throws InvalidConfigException
525
     *
526
     * @return mixed|object
527
     */
528 126
    private function buildInternal(string $id)
529
    {
530 126
        if ($this->definitions->has($id)) {
531 126
            $definition = DefinitionNormalizer::normalize($this->definitions->get($id), $id);
532
533 126
            return $definition->resolve($this->get(ContainerInterface::class));
534
        }
535
536 12
        throw new NotFoundException($id, $this->definitions->getBuildStack());
537
    }
538
539
    /**
540
     * @throws CircularReferenceException
541
     * @throws InvalidConfigException
542
     */
543 132
    private function addProviders(array $providers): void
544
    {
545 132
        $extensions = [];
546
        /** @var mixed $provider */
547 132
        foreach ($providers as $provider) {
548 16
            $providerInstance = $this->buildProvider($provider);
549 14
            $extensions[] = $providerInstance->getExtensions();
550 14
            $this->addDefinitions($providerInstance->getDefinitions());
551
        }
552
553 130
        foreach ($extensions as $providerExtensions) {
554
            /** @var mixed $extension */
555 14
            foreach ($providerExtensions as $id => $extension) {
556 10
                if (!is_string($id)) {
557 1
                    throw new InvalidConfigException(
558 1
                        sprintf('Extension key must be a service ID as string, %s given.', $id)
559 1
                    );
560
                }
561
562 9
                if ($id === ContainerInterface::class) {
563 1
                    throw new InvalidConfigException('ContainerInterface extensions are not allowed.');
564
                }
565
566 8
                if (!$this->definitions->has($id)) {
567 1
                    throw new InvalidConfigException("Extended service \"$id\" doesn't exist.");
568
                }
569
570 8
                if (!is_callable($extension)) {
571 1
                    throw new InvalidConfigException(
572 1
                        sprintf(
573 1
                            'Extension of service should be callable, %s given.',
574 1
                            get_debug_type($extension)
575 1
                        )
576 1
                    );
577
                }
578
579
                /** @var mixed $definition */
580 7
                $definition = $this->definitions->get($id);
581 7
                if (!$definition instanceof ExtensibleService) {
582 7
                    $definition = new ExtensibleService($definition, $id);
583 7
                    $this->addDefinitionToStorage($id, $definition);
584
                }
585
586 7
                $definition->addExtension($extension);
587
            }
588
        }
589
    }
590
591
    /**
592
     * Builds service provider by definition.
593
     *
594
     * @param mixed $provider Class name or instance of provider.
595
     *
596
     * @throws InvalidConfigException If provider argument is not valid.
597
     *
598
     * @return ServiceProviderInterface Instance of service provider.
599
     */
600 16
    private function buildProvider(mixed $provider): ServiceProviderInterface
601
    {
602 16
        if ($this->validate && !(is_string($provider) || $provider instanceof ServiceProviderInterface)) {
603 1
            throw new InvalidConfigException(
604 1
                sprintf(
605 1
                    'Service provider should be a class name or an instance of %s. %s given.',
606 1
                    ServiceProviderInterface::class,
607 1
                    get_debug_type($provider)
608 1
                )
609 1
            );
610
        }
611
612
        /**
613
         * @psalm-suppress MixedMethodCall Service provider defined as class string
614
         * should container public constructor, otherwise throws error.
615
         */
616 15
        $providerInstance = is_object($provider) ? $provider : new $provider();
617 15
        if (!$providerInstance instanceof ServiceProviderInterface) {
618 1
            throw new InvalidConfigException(
619 1
                sprintf(
620 1
                    'Service provider should be an instance of %s. %s given.',
621 1
                    ServiceProviderInterface::class,
622 1
                    get_debug_type($providerInstance)
623 1
                )
624 1
            );
625
        }
626
627 14
        return $providerInstance;
628
    }
629
630 6
    private function decorateLazy(string $id, mixed $definition): DefinitionInterface
631
    {
632 6
        $class = class_exists($id) || interface_exists($id) ? $id : null;
633
634 6
        if (is_array($definition) && isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) {
635 4
            if (empty($class)) {
636 1
                $class = $definition[ArrayDefinition::CLASS_NAME];
637
            }
638 4
            $preparedDefinition = ArrayDefinition::fromPreparedData(
639 4
                $definition[ArrayDefinition::CLASS_NAME] ?? $class,
640 4
                $definition[ArrayDefinition::CONSTRUCTOR],
641 4
                $definition['methodsAndProperties'],
642 4
            );
643
        } else {
644 2
            $preparedDefinition = $definition;
645
        }
646
647 6
        if (empty($class)) {
648
            throw new InvalidConfigException(
649
                sprintf(
650
                    'Invalid definition: lazy services are only available with array definitions or references. Got type "%s" for definition ID: "%s"',
651
                    get_debug_type($definition),
652
                    $id,
653
                )
654
            );
655
        }
656
657 6
        return new LazyDefinition($preparedDefinition, $class);
658
    }
659
}
660