Test Failed
Push — main ( 60b179...398284 )
by Chema
02:33 queued 01:19
created

Container::extend()   B

Complexity

Conditions 7
Paths 5

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 7

Importance

Changes 0
Metric Value
eloc 14
c 0
b 0
f 0
nc 5
nop 2
dl 0
loc 26
cc 7
rs 8.8333
ccs 14
cts 14
cp 1
crap 7
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 InvalidArgumentException;
10
11
use SplObjectStorage;
12
13
use function count;
14
use function get_class;
15
use function is_array;
16
use function is_callable;
17
use function is_object;
18
use function is_string;
19
20
class Container implements ContainerInterface
21
{
22
    private ?DependencyResolver $dependencyResolver = null;
23
24
    /** @var array<class-string|string, list<mixed>> */
25
    private array $cachedDependencies = [];
26
27
    /** @var array<string,mixed> */
28
    private array $instances = [];
29
30
    private SplObjectStorage $factoryInstances;
31
32
    private SplObjectStorage $protectedInstances;
33
34
    /** @var array<string,bool> */
35
    private array $frozenInstances = [];
36
37
    private ?string $currentlyExtending = null;
38
39
    /**
40 42
     * @param  array<class-string, class-string|callable|object>  $bindings
41
     * @param  array<string, list<Closure>>  $instancesToExtend
42
     */
43
    public function __construct(
44 42
        private array $bindings = [],
45 42
        private array $instancesToExtend = [],
46
    ) {
47
        $this->factoryInstances = new SplObjectStorage();
48
        $this->protectedInstances = new SplObjectStorage();
49
    }
50
51 5
    /**
52
     * @param  class-string  $className
53 5
     */
54
    public static function create(string $className): mixed
55
    {
56 37
        return (new self())->get($className);
57
    }
58 37
59
    public function has(string $id): bool
60
    {
61 19
        return isset($this->instances[$id]);
62
    }
63 19
64 1
    public function set(string $id, mixed $instance): void
65
    {
66
        if (!empty($this->frozenInstances[$id])) {
67 19
            throw ContainerException::frozenInstanceOverride($id);
68
        }
69 19
70 3
        $this->instances[$id] = $instance;
71
72
        if ($this->currentlyExtending === $id) {
73 19
            return;
74
        }
75
76
        $this->extendService($id);
77
    }
78
79 33
    /**
80
     * @param  class-string|string  $id
81 33
     */
82 15
    public function get(string $id): mixed
83
    {
84
        if ($this->has($id)) {
85 18
            return $this->getInstance($id);
86
        }
87
88 5
        return $this->createInstance($id);
89
    }
90 5
91 5
    public function resolve(callable $callable): mixed
92 5
    {
93
        $callableKey = $this->callableKey($callable);
94 5
        $callable = Closure::fromCallable($callable);
95 5
96 5
        if (!isset($this->cachedDependencies[$callableKey])) {
97 5
            $this->cachedDependencies[$callableKey] = $this
98
                ->getDependencyResolver()
99
                ->resolveDependencies($callable);
100
        }
101 5
102
        /** @psalm-suppress MixedMethodCall */
103
        return $callable(...$this->cachedDependencies[$callableKey]);
104 1
    }
105
106 1
    public function factory(Closure $instance): Closure
107
    {
108 1
        $this->factoryInstances->attach($instance);
109
110
        return $instance;
111 1
    }
112
113 1
    public function remove(string $id): void
114 1
    {
115 1
        unset(
116 1
            $this->instances[$id],
117
            $this->frozenInstances[$id],
118
        );
119
    }
120
121
    /**
122 11
     * @psalm-suppress MixedAssignment
123
     */
124 11
    public function extend(string $id, Closure $instance): Closure
125 4
    {
126
        if (!$this->has($id)) {
127 4
            $this->extendLater($id, $instance);
128
129
            return $instance;
130 10
        }
131 4
132
        if (isset($this->frozenInstances[$id])) {
133
            throw ContainerException::frozenInstanceExtend($id);
134 8
        }
135 1
136
        if (is_object($this->instances[$id]) && isset($this->protectedInstances[$this->instances[$id]])) {
137
            throw ContainerException::instanceProtected($id);
138 7
        }
139 7
140 6
        $factory = $this->instances[$id];
141
        $extended = $this->generateExtendedInstance($instance, $factory);
142 6
        $this->set($id, $extended);
143
144
        if (is_object($factory) && isset($this->factoryInstances[$factory])) {
145 2
            $this->factoryInstances->detach($factory);
146
            $this->factoryInstances->attach($extended);
147 2
        }
148
149 2
        return $extended;
150
    }
151
152 15
    public function protect(Closure $instance): Closure
153
    {
154 15
        $this->protectedInstances->attach($instance);
155
156 15
        return $instance;
157 14
    }
158 15
159
    private function getInstance(string $id): mixed
160 8
    {
161
        $this->frozenInstances[$id] = true;
162
163 12
        if (!is_object($this->instances[$id])
164 1
            || isset($this->protectedInstances[$this->instances[$id]])
165
            || !method_exists($this->instances[$id], '__invoke')
166
        ) {
167 11
            return $this->instances[$id];
168
        }
169
170 11
        if (isset($this->factoryInstances[$this->instances[$id]])) {
171
            return $this->instances[$id]($this);
172 11
        }
173
174 11
        $rawService = $this->instances[$id];
175
176
        /** @var mixed $resolvedService */
177 18
        $resolvedService = $rawService($this);
178
179 18
        $this->instances[$id] = $resolvedService;
180 4
181 4
        return $resolvedService;
182
    }
183 2
184
    private function createInstance(string $class): ?object
185 4
    {
186 2
        if (isset($this->bindings[$class])) {
187
            $binding = $this->bindings[$class];
188
            if (is_callable($binding)) {
189
                /** @var mixed $binding */
190 2
                $binding = $binding();
191 2
            }
192
            if (is_object($binding)) {
193
                return $binding;
194
            }
195 14
196 10
            /** @var class-string $binding */
197
            if (class_exists($binding)) {
198
                return $this->instantiateClass($binding);
199 4
            }
200
        }
201
202
        if (class_exists($class)) {
203
            return $this->instantiateClass($class);
204
        }
205 12
206
        return null;
207 12
    }
208 12
209 12
    /**
210 12
     * @param  class-string  $class
211 12
     */
212
    private function instantiateClass(string $class): ?object
213
    {
214
        if (class_exists($class)) {
215 12
            if (!isset($this->cachedDependencies[$class])) {
216
                $this->cachedDependencies[$class] = $this
217
                    ->getDependencyResolver()
218
                    ->resolveDependencies($class);
219
            }
220
221 4
            /** @psalm-suppress MixedMethodCall */
222
            return new $class(...$this->cachedDependencies[$class]);
223 4
        }
224
225
        return null;
226 17
    }
227
228 17
    private function extendLater(string $id, Closure $instance): void
229 17
    {
230 17
        $this->instancesToExtend[$id][] = $instance;
231 17
    }
232
233
    private function getDependencyResolver(): DependencyResolver
234 17
    {
235
        if ($this->dependencyResolver === null) {
236
            $this->dependencyResolver = new DependencyResolver(
237
                $this->bindings,
238
            );
239
        }
240 7
241
        return $this->dependencyResolver;
242 7
    }
243 5
244 5
    /**
245
     * Generates a unique string key for a given callable.
246 5
     *
247 5
     * @psalm-suppress MixedReturnTypeCoercion
248
     */
249
    private function callableKey(callable $callable): string
250 4
    {
251 3
        if (is_array($callable)) {
252
            [$classOrObject, $method] = $callable;
253
254 1
            $className = is_object($classOrObject)
255
                ? get_class($classOrObject)
256
                : $classOrObject;
257 19
258
            return $className . '::' . $method;
259 19
        }
260 16
261
        if (is_string($callable)) {
262 3
            return $callable;
263
        }
264 3
265 3
        if ($callable instanceof Closure) {
266
            return spl_object_hash($callable);
267
        }
268 3
269 3
        throw new InvalidArgumentException('Unsupported callable type');
270
    }
271
272
    /**
273
     * @psalm-suppress MissingClosureReturnType,MixedAssignment
274
     */
275
    private function generateExtendedInstance(Closure $instance, mixed $factory): Closure
276
    {
277
        if (is_callable($factory)) {
278
            return static function (self $container) use ($instance, $factory) {
279
                $result = $factory($container);
280
281
                return $instance($result, $container) ?? $result;
282
            };
283
        }
284
285
        if (is_object($factory) || is_array($factory)) {
286
            return static fn (self $container) => $instance($factory, $container) ?? $factory;
287
        }
288
289
        throw ContainerException::instanceNotExtendable();
290
    }
291
292
    private function extendService(string $id): void
293
    {
294
        if (!isset($this->instancesToExtend[$id]) || count($this->instancesToExtend[$id]) === 0) {
295
            return;
296
        }
297
        $this->currentlyExtending = $id;
298
299
        foreach ($this->instancesToExtend[$id] as $instance) {
300
            $this->extend($id, $instance);
301
        }
302
303
        unset($this->instancesToExtend[$id]);
304
        $this->currentlyExtending = null;
305
    }
306
}
307