Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
202 / 202
96.08% covered (success)
96.08%
147 / 153
47.97% covered (danger)
47.97%
71 / 148
83.33% covered (warning)
83.33%
20 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Container
100.00% covered (success)
100.00%
202 / 202
96.08% covered (success)
96.08%
147 / 153
47.97% covered (danger)
47.97%
71 / 148
100.00% covered (success)
100.00%
24 / 24
981.30
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 scope
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 reset
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 has
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 entries
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 entry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
30 / 30
95.24% covered (success)
95.24%
20 / 21
66.67% covered (warning)
66.67%
8 / 12
100.00% covered (success)
100.00%
1 / 1
12.00
 definition
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 add
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addEntry
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tag
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
9 / 9
57.14% covered (warning)
57.14%
4 / 7
100.00% covered (success)
100.00%
1 / 1
6.97
 new
100.00% covered (success)
100.00%
11 / 11
86.67% covered (warning)
86.67%
13 / 15
21.05% covered (danger)
21.05%
4 / 19
100.00% covered (success)
100.00%
1 / 1
23.71
 resolveEntry
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
9 / 9
57.14% covered (warning)
57.14%
4 / 7
100.00% covered (success)
100.00%
1 / 1
6.97
 resolutionContainers
100.00% covered (success)
100.00%
5 / 5
77.78% covered (warning)
77.78%
7 / 9
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
4.25
 materialize
100.00% covered (success)
100.00%
35 / 35
95.24% covered (success)
95.24%
20 / 21
39.13% covered (danger)
39.13%
9 / 23
100.00% covered (success)
100.00%
1 / 1
32.55
 applyCalls
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
4 / 4
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
 resetScope
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
12 / 12
13.89% covered (danger)
13.89%
5 / 36
100.00% covered (success)
100.00%
1 / 1
20.96
 resetIfNeeded
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 trackAndReturn
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 findEntry
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 root
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 assertMutable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 seal
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
4 / 4
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
 isRoot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
5 / 5
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Container;
6
7use Celemas\Container\Exception\ContainerException;
8use Celemas\Container\Exception\NotFoundException;
9use Celemas\Wire\CallableResolver;
10use Celemas\Wire\Creator;
11use Celemas\Wire\Exception\WireException;
12use Celemas\Wire\WireContainer;
13use Closure;
14use Override;
15use Psr\Container\ContainerExceptionInterface;
16use Psr\Container\ContainerInterface as PsrContainer;
17use Throwable;
18
19/** @psalm-api */
20class Container implements WireContainer
21{
22    protected Creator $creator;
23    protected readonly ?PsrContainer $wrappedContainer;
24    protected bool $sealed = false;
25
26    /** @var array<string, Entry> */
27    protected array $entries = [];
28
29    /** @var array<string, mixed> */
30    protected array $instances = [];
31
32    /** @var array<non-empty-string, self> */
33    protected array $tags = [];
34
35    /** @var array<int, Resettable> */
36    protected array $usedResettables = [];
37
38    public function __construct(
39        public readonly bool $autowire = true,
40        ?PsrContainer $container = null,
41        protected readonly string $tag = '',
42        protected readonly ?Container $parent = null,
43        protected readonly bool $isScope = false,
44    ) {
45        if ($container) {
46            $this->wrappedContainer = $container;
47            $this->add(PsrContainer::class, $container);
48            $this->add($container::class, $container);
49        } else {
50            $this->wrappedContainer = null;
51            $this->add(PsrContainer::class, $this);
52        }
53        $this->add(Container::class, $this);
54        $this->creator = new Creator($this);
55    }
56
57    public function scope(): Container
58    {
59        $root = $this->root();
60
61        if (!$root->sealed) {
62            $root->seal();
63        }
64
65        return new self(
66            autowire: $root->autowire,
67            parent: $root,
68            isScope: true,
69        );
70    }
71
72    public function reset(): void
73    {
74        if (!$this->isScope) {
75            return;
76        }
77
78        $resetIds = [];
79        $this->resetScope($resetIds);
80    }
81
82    #[Override]
83    public function has(string $id): bool
84    {
85        return (
86            isset($this->entries[$id])
87            || $this->parent?->has($id)
88            || $this->wrappedContainer?->has($id)
89        );
90    }
91
92    /** @return list<string> */
93    public function entries(bool $includeContainer = false): array
94    {
95        $keys = array_keys($this->entries);
96
97        if ($includeContainer) {
98            return $keys;
99        }
100
101        return array_values(array_filter(
102            $keys,
103            static fn($item) => $item !== PsrContainer::class && !is_subclass_of($item, PsrContainer::class),
104        ));
105    }
106
107    public function entry(string $id): Entry
108    {
109        return $this->entries[$id];
110    }
111
112    #[Override]
113    public function get(string $id): mixed
114    {
115        try {
116            if (array_key_exists($id, $this->instances)) {
117                return $this->trackAndReturn($this->instances[$id]);
118            }
119
120            $resolved = $this->findEntry($id);
121
122            if ($resolved !== null) {
123                return $this->trackAndReturn(
124                    $this->resolveEntry(
125                        entryOwner: $resolved[0],
126                        entry: $resolved[1],
127                        id: $id,
128                        requester: $this,
129                    ),
130                );
131            }
132
133            $wrappedContainer = $this->root()->wrappedContainer;
134
135            if ($wrappedContainer?->has($id)) {
136                return $this->trackAndReturn($wrappedContainer->get($id));
137            }
138
139            // Autowiring: $id does not exists as an entry in the container
140            if ($this->autowire && class_exists($id)) {
141                return $this->trackAndReturn($this->creator->create($id));
142            }
143        } catch (WireException $e) {
144            throw new NotFoundException(
145                'Unresolvable id: ' . $id . ' - Details: ' . $e->getMessage(),
146                previous: $e,
147            );
148        } catch (ContainerExceptionInterface $e) {
149            throw $e;
150        } catch (Throwable $e) {
151            throw new ContainerException(
152                'Unresolvable id: ' . $id . ' - ' . $e->getMessage(),
153                previous: $e,
154            );
155        }
156
157        throw new NotFoundException('Unresolvable id: ' . $id);
158    }
159
160    #[Override]
161    public function definition(string $id): mixed
162    {
163        $resolved = $this->findEntry($id);
164        $entry = $resolved[1] ?? null;
165
166        if ($entry !== null) {
167            return $entry->definition();
168        }
169
170        throw new NotFoundException('Unresolvable definition - id: ' . $id);
171    }
172
173    /**
174     * @param non-empty-string $id
175     */
176    public function add(
177        string $id,
178        mixed $value = null,
179    ): Entry {
180        $this->assertMutable();
181        $entry = new Entry($id, $value ?? $id);
182        $this->entries[$id] = $entry;
183        unset($this->instances[$id]);
184
185        return $entry;
186    }
187
188    public function addEntry(
189        Entry $entry,
190    ): Entry {
191        $this->assertMutable();
192        $this->entries[$entry->id] = $entry;
193        unset($this->instances[$entry->id]);
194
195        return $entry;
196    }
197
198    /** @param non-empty-string $tag */
199    public function tag(string $tag): Container
200    {
201        if (isset($this->tags[$tag])) {
202            return $this->tags[$tag];
203        }
204
205        if ($this->isRoot() && $this->sealed) {
206            throw new ContainerException('The root container is sealed after scope() was called');
207        }
208
209        $parent = $this;
210        $isScope = false;
211
212        if ($this->isScope) {
213            $root = $this->root();
214            $parent = $root->tags[$tag] ?? $root;
215            $isScope = true;
216        }
217
218        $this->tags[$tag] = new self(
219            autowire: $this->autowire,
220            tag: $tag,
221            parent: $parent,
222            isScope: $isScope,
223        );
224
225        return $this->tags[$tag];
226    }
227
228    public function new(string $id, mixed ...$args): object
229    {
230        $entry = $this->entries[$id] ?? null;
231
232        if ($entry) {
233            /** @var mixed $value */
234            $value = $entry->definition();
235
236            if (is_string($value)) {
237                if (interface_exists($value)) {
238                    return $this->new($value, ...$args);
239                }
240
241                if (class_exists($value)) {
242                    /** @psalm-suppress MixedMethodCall */
243                    return new $value(...$args);
244                }
245            }
246        }
247
248        if (class_exists($id)) {
249            /** @psalm-suppress MixedMethodCall */
250            return new $id(...$args);
251        }
252
253        throw new NotFoundException('Cannot instantiate ' . $id);
254    }
255
256    protected function resolveEntry(
257        Container $entryOwner,
258        Entry $entry,
259        string $id,
260        Container $requester,
261    ): mixed {
262        if ($entry->shouldReturnValue()) {
263            return $entry->definition();
264        }
265
266        [$cacheContainer, $resolutionContext] = $this->resolutionContainers(
267            $entry,
268            $entryOwner,
269            $requester,
270        );
271
272        if ($cacheContainer !== null && array_key_exists($id, $cacheContainer->instances)) {
273            return $cacheContainer->instances[$id];
274        }
275
276        // materialize() intentionally returns mixed because container entries may resolve to any value type.
277        /** @psalm-suppress MixedAssignment */
278        $result = $this->materialize($entry, $resolutionContext);
279
280        if ($cacheContainer !== null) {
281            $cacheContainer->instances[$id] = $result;
282        }
283
284        return $result;
285    }
286
287    /**
288     * @return array{0: null|Container, 1: Container}
289     */
290    protected function resolutionContainers(
291        Entry $entry,
292        Container $entryOwner,
293        Container $requester,
294    ): array {
295        return match ($entry->getLifetime()) {
296            Lifetime::Shared => [$entryOwner, $entryOwner],
297            Lifetime::Scoped => [$requester, $requester],
298            Lifetime::Transient => [null, $requester],
299        };
300    }
301
302    protected function materialize(Entry $entry, Container $context): mixed
303    {
304        /** @var mixed $value */
305        $value = $entry->definition();
306
307        if (is_string($value)) {
308            if (class_exists($value)) {
309                $constructor = $entry->getConstructor();
310                $args = $entry->getArgs();
311
312                if (isset($args)) {
313                    // Don't autowire if $args are given
314                    if ($args instanceof Closure) {
315                        $args = $args(...new CallableResolver($context->creator)->resolve($args));
316
317                        return $this->applyCalls($entry, $context->creator->create($value, $args), $context);
318                    }
319
320                    return $this->applyCalls(
321                        $entry,
322                        $context->creator->create(
323                            $value,
324                            predefinedArgs: $args,
325                            constructor: $constructor ?? '',
326                        ),
327                        $context,
328                    );
329                }
330
331                return $this->applyCalls(
332                    $entry,
333                    $context->creator->create($value, constructor: $constructor ?? ''),
334                    $context,
335                );
336            }
337
338            if ($context->has($value)) {
339                return $context->get($value);
340            }
341        }
342
343        if ($value instanceof Closure) {
344            $args = $entry->getArgs();
345
346            if (is_null($args)) {
347                $args = new CallableResolver($context->creator)->resolve($value);
348            } elseif ($args instanceof Closure) {
349                $args = $args();
350            }
351
352            return $this->applyCalls($entry, $value(...$args), $context);
353        }
354
355        if (is_object($value)) {
356            return $value;
357        }
358
359        throw new NotFoundException('Unresolvable id: ' . (string) $value);
360    }
361
362    protected function applyCalls(Entry $entry, mixed $value, Container $context): mixed
363    {
364        foreach ($entry->getCalls() as $call) {
365            $methodToResolve = $call->method;
366
367            /** @var callable */
368            $callable = [$value, $methodToResolve];
369            $args = new CallableResolver($context->creator)->resolve($callable, $call->args);
370            $callable(...$args);
371        }
372
373        return $value;
374    }
375
376    /**
377     * @param array<int, true> $resetIds
378     */
379    protected function resetScope(array &$resetIds): void
380    {
381        // $instances stores mixed values by design, so foreach assigns mixed to $instance.
382        /** @psalm-suppress MixedAssignment */
383        foreach ($this->instances as $instance) {
384            $this->resetIfNeeded($instance, $resetIds);
385        }
386
387        foreach ($this->usedResettables as $usedResettable) {
388            $this->resetIfNeeded($usedResettable, $resetIds);
389        }
390
391        foreach ($this->tags as $tagContainer) {
392            if (!$tagContainer->isScope) {
393                continue;
394            }
395
396            $tagContainer->resetScope($resetIds);
397        }
398
399        $this->instances = [];
400        $this->entries = [];
401        $this->tags = [];
402        $this->usedResettables = [];
403        $this->add(PsrContainer::class, $this);
404        $this->add(Container::class, $this);
405    }
406
407    /**
408     * @param array<int, true> $resetIds
409     */
410    protected function resetIfNeeded(mixed $value, array &$resetIds): void
411    {
412        if (!$value instanceof Resettable) {
413            return;
414        }
415
416        $objectId = spl_object_id($value);
417
418        if (isset($resetIds[$objectId])) {
419            return;
420        }
421
422        $resetIds[$objectId] = true;
423        $value->reset();
424    }
425
426    protected function trackAndReturn(mixed $value): mixed
427    {
428        if ($this->isScope && $value instanceof Resettable) {
429            $this->usedResettables[spl_object_id($value)] = $value;
430        }
431
432        return $value;
433    }
434
435    /** @return array{Container, Entry}|null */
436    protected function findEntry(string $id): ?array
437    {
438        $entry = $this->entries[$id] ?? null;
439
440        if ($entry !== null) {
441            return [$this, $entry];
442        }
443
444        return $this->parent?->findEntry($id);
445    }
446
447    protected function root(): Container
448    {
449        $container = $this;
450
451        while ($container->parent !== null) {
452            $container = $container->parent;
453        }
454
455        return $container;
456    }
457
458    protected function assertMutable(): void
459    {
460        if ($this->sealed) {
461            throw new ContainerException('The root container is sealed after scope() was called');
462        }
463    }
464
465    protected function seal(): void
466    {
467        $this->sealed = true;
468
469        foreach ($this->tags as $tagContainer) {
470            $tagContainer->seal();
471        }
472    }
473
474    protected function isRoot(): bool
475    {
476        return $this->parent === null && $this->tag === '' && !$this->isScope;
477    }
478}