Passed
Pull Request — main (#13)
by Chema
02:23
created

Container::extend()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 21
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 5

Importance

Changes 0
Metric Value
eloc 11
c 0
b 0
f 0
dl 0
loc 21
rs 9.6111
ccs 12
cts 12
cp 1
cc 5
nc 4
nop 2
crap 5
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Gacela\Container;
6
7
use Closure;
8
use Gacela\Container\Exception\ContainerException;
9
use ReflectionFunction;
10
use SplObjectStorage;
11
12
use function count;
13
use function is_array;
14
use function is_callable;
15
use function is_object;
16
17
class Container implements ContainerInterface
18
{
19
    private ?DependencyResolver $dependencyResolver = null;
20
21
    /** @var array<class-string|string, list<mixed>> */
1 ignored issue
show
Documentation Bug introduced by
The doc comment array<class-string|string, list<mixed>> at position 2 could not be parsed: Unknown type name 'class-string' at position 2 in array<class-string|string, list<mixed>>.
Loading history...
22
    private array $cachedDependencies = [];
23
24
    /** @var array<string,mixed> */
25
    private array $instances = [];
26
27
    private SplObjectStorage $factoryInstances;
28
29
    private SplObjectStorage $protectedInstances;
30
31
    /** @var array<string,bool> */
32
    private array $frozenInstances = [];
33
34
    private ?string $currentlyExtending = null;
35
36
    /**
37
     * @param array<class-string, class-string|callable|object> $bindings
38
     * @param array<string, list<Closure>> $instancesToExtend
39
     */
40 42
    public function __construct(
41
        private array $bindings = [],
42
        private array $instancesToExtend = [],
43
    ) {
44 42
        $this->factoryInstances = new SplObjectStorage();
45 42
        $this->protectedInstances = new SplObjectStorage();
46
    }
47
48
    /**
49
     * @param class-string $className
50
     */
51 5
    public static function create(string $className): mixed
52
    {
53 5
        return (new self())->get($className);
54
    }
55
56 3
    public static function resolveClosure(Closure $closure): mixed
57
    {
58 3
        return (new self())->resolve($closure);
59
    }
60
61 37
    public function has(string $id): bool
62
    {
63 37
        return isset($this->instances[$id]);
64
    }
65
66 19
    public function set(string $id, mixed $instance): void
67
    {
68 19
        if (!empty($this->frozenInstances[$id])) {
69 1
            throw ContainerException::frozenInstanceOverride($id);
70
        }
71
72 19
        $this->instances[$id] = $instance;
73
74 19
        if ($this->currentlyExtending === $id) {
75 3
            return;
76
        }
77
78 19
        $this->extendService($id);
79
    }
80
81
    /**
82
     * @param class-string|string $id
83
     */
84 33
    public function get(string $id): mixed
85
    {
86 33
        if ($this->has($id)) {
87 15
            return $this->getInstance($id);
88
        }
89
90 18
        return $this->createInstance($id);
91
    }
92
93 5
    public function resolve(Closure $closure): mixed
94
    {
95 5
        $reflectionFn = new ReflectionFunction($closure);
96 5
        $callableKey = md5(serialize($reflectionFn->__toString()));
97
98 5
        if (!isset($this->cachedDependencies[$callableKey])) {
99 5
            $this->cachedDependencies[$callableKey] = $this
100 5
                ->getDependencyResolver()
101 5
                ->resolveDependencies($closure);
102
        }
103
104
        /** @psalm-suppress MixedMethodCall */
105 5
        return $closure(...$this->cachedDependencies[$callableKey]);
106
    }
107
108 1
    public function factory(Closure $instance): Closure
109
    {
110 1
        $this->factoryInstances->attach($instance);
111
112 1
        return $instance;
113
    }
114
115 1
    public function remove(string $id): void
116
    {
117 1
        unset(
118 1
            $this->instances[$id],
119 1
            $this->frozenInstances[$id]
120 1
        );
121
    }
122
123
    /**
124
     * @psalm-suppress MixedAssignment
125
     */
126 11
    public function extend(string $id, Closure $instance): Closure
127
    {
128 11
        if (!$this->has($id)) {
129 4
            $this->extendLater($id, $instance);
130
131 4
            return $instance;
132
        }
133
134 10
        if (isset($this->frozenInstances[$id])) {
135 4
            throw ContainerException::frozenInstanceExtend($id);
136
        }
137
138 8
        if (is_object($this->instances[$id]) && isset($this->protectedInstances[$this->instances[$id]])) {
139 1
            throw ContainerException::instanceProtected($id);
140
        }
141
142 7
        $factory = $this->instances[$id];
143 7
        $extended = $this->generateExtendedInstance($instance, $factory);
144 6
        $this->set($id, $extended);
145
146 6
        return $extended;
147
    }
148
149 2
    public function protect(Closure $instance): Closure
150
    {
151 2
        $this->protectedInstances->attach($instance);
152
153 2
        return $instance;
154
    }
155
156 15
    private function getInstance(string $id): mixed
157
    {
158 15
        $this->frozenInstances[$id] = true;
159
160 15
        if (!is_object($this->instances[$id])
161 14
            || isset($this->protectedInstances[$this->instances[$id]])
162 15
            || !method_exists($this->instances[$id], '__invoke')
163
        ) {
164 8
            return $this->instances[$id];
165
        }
166
167 12
        if (isset($this->factoryInstances[$this->instances[$id]])) {
168 1
            return $this->instances[$id]($this);
169
        }
170
171 11
        $rawService = $this->instances[$id];
172
173
        /** @var mixed $resolvedService */
174 11
        $resolvedService = $rawService($this);
175
176 11
        $this->instances[$id] = $resolvedService;
177
178 11
        return $resolvedService;
179
    }
180
181 18
    private function createInstance(string $class): ?object
182
    {
183 18
        if (isset($this->bindings[$class])) {
184 4
            $binding = $this->bindings[$class];
185 4
            if (is_callable($binding)) {
186
                /** @var mixed $binding */
187 2
                $binding = $binding();
188
            }
189 4
            if (is_object($binding)) {
190 2
                return $binding;
191
            }
192
193
            /** @var class-string $binding */
194 2
            if (class_exists($binding)) {
195 2
                return $this->instantiateClass($binding);
196
            }
197
        }
198
199 14
        if (class_exists($class)) {
200 10
            return $this->instantiateClass($class);
201
        }
202
203 4
        return null;
204
    }
205
206
    /**
207
     * @param class-string $class
208
     */
209 12
    private function instantiateClass(string $class): ?object
210
    {
211 12
        if (class_exists($class)) {
212 12
            if (!isset($this->cachedDependencies[$class])) {
213 12
                $this->cachedDependencies[$class] = $this
214 12
                    ->getDependencyResolver()
215 12
                    ->resolveDependencies($class);
216
            }
217
218
            /** @psalm-suppress MixedMethodCall */
219 12
            return new $class(...$this->cachedDependencies[$class]);
220
        }
221
222
        return null;
223
    }
224
225 4
    private function extendLater(string $id, Closure $instance): void
226
    {
227 4
        $this->instancesToExtend[$id][] = $instance;
228
    }
229
230 17
    private function getDependencyResolver(): DependencyResolver
231
    {
232 17
        if ($this->dependencyResolver === null) {
233 17
            $this->dependencyResolver = new DependencyResolver(
234 17
                $this->bindings,
235 17
            );
236
        }
237
238 17
        return $this->dependencyResolver;
1 ignored issue
show
Bug Best Practice introduced by
The expression return $this->dependencyResolver could return the type null which is incompatible with the type-hinted return Gacela\Container\DependencyResolver. Consider adding an additional type-check to rule them out.
Loading history...
239
    }
240
241
    /**
242
     * @psalm-suppress MissingClosureReturnType,MixedAssignment
243
     */
244 7
    private function generateExtendedInstance(Closure $instance, mixed $factory): Closure
245
    {
246 7
        if (is_callable($factory)) {
247 5
            return static function (self $container) use ($instance, $factory) {
248 5
                $result = $factory($container);
249
250 5
                return $instance($result, $container) ?? $result;
251 5
            };
252
        }
253
254 4
        if (is_object($factory) || is_array($factory)) {
255 3
            return static fn (self $container) => $instance($factory, $container) ?? $factory;
256
        }
257
258 1
        throw ContainerException::instanceNotExtendable();
259
    }
260
261 19
    private function extendService(string $id): void
262
    {
263 19
        if (!isset($this->instancesToExtend[$id]) || count($this->instancesToExtend[$id]) === 0) {
264 16
            return;
265
        }
266 3
        $this->currentlyExtending = $id;
267
268 3
        foreach ($this->instancesToExtend[$id] as $instance) {
269 3
            $this->extend($id, $instance);
270
        }
271
272 3
        unset($this->instancesToExtend[$id]);
273 3
        $this->currentlyExtending = null;
274
    }
275
}
276