Test Failed
Push — lazy-services ( 2f8c8b...8212a3 )
by Dmitriy
16:14 queued 13:07
created

Container::isTagAlias()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
621
    {
622
        if (is_object($variable)) {
623
            return get_class($variable);
624
        }
625
626
        return gettype($variable);
627
    }
628
629
    private function decorateLazy(string $id, DefinitionInterface $definition): DefinitionInterface
0 ignored issues
show
Bug introduced by
The type Yiisoft\Di\DefinitionInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
630
    {
631
        $factory = $this->getLazyLoadingValueHolderFactory();
632
        if (class_exists($id) || interface_exists($id)) {
633
            $class = $id;
634
        } elseif ($definition instanceof ArrayDefinition) {
635
            $class = $definition->getClass();
636
        } else {
637
            throw new \RuntimeException('Could not determinate object class');
638
        }
639
640
        return new LazyDefinitionDecorator($factory, $definition, $class);
0 ignored issues
show
Bug introduced by
The type Yiisoft\Di\LazyDefinitionDecorator was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
641
    }
642
643
    private function getLazyLoadingValueHolderFactory(): LazyLoadingValueHolderFactory
644
    {
645
        if (!class_exists(LazyLoadingValueHolderFactory::class)) {
646
            throw new \RuntimeException('You should install `ocramius/proxy-manager` if you want to use lazy services.');
647
        }
648
        return $this->lazyFactory ??= new LazyLoadingValueHolderFactory();
649
    }
650
}
651