Test Failed
Push — main ( ace48b...917e6f )
by Chema
23:03 queued 20:04
created

Container::isFrozen()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

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