Test Failed
Push — main ( 917e6f...6ea8e5 )
by Chema
04:26 queued 01:24
created

Container::resolveAlias()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 0
cts 0
cp 0
crap 2
rs 10
c 0
b 0
f 0
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
    /** @var array<string,string> */
37
    private array $aliases = [];
38
39
    private ?string $currentlyExtending = null;
40 42
41
    /**
42
     * @param  array<class-string, class-string|callable|object>  $bindings
43
     * @param  array<string, list<Closure>>  $instancesToExtend
44 42
     */
45 42
    public function __construct(
46
        private array $bindings = [],
47
        private array $instancesToExtend = [],
48
    ) {
49
        $this->factoryInstances = new SplObjectStorage();
50
        $this->protectedInstances = new SplObjectStorage();
51 5
    }
52
53 5
    /**
54
     * @param  class-string  $className
55
     */
56 37
    public static function create(string $className): mixed
57
    {
58 37
        return (new self())->get($className);
59
    }
60
61 19
    public function has(string $id): bool
62
    {
63 19
        $id = $this->resolveAlias($id);
64 1
        return isset($this->instances[$id]);
65
    }
66
67 19
    public function set(string $id, mixed $instance): void
68
    {
69 19
        if (!empty($this->frozenInstances[$id])) {
70 3
            throw ContainerException::frozenInstanceOverride($id);
71
        }
72
73 19
        $this->instances[$id] = $instance;
74
75
        if ($this->currentlyExtending === $id) {
76
            return;
77
        }
78
79 33
        $this->extendService($id);
80
    }
81 33
82 15
    /**
83
     * @param  class-string|string  $id
84
     */
85 18
    public function get(string $id): mixed
86
    {
87
        $id = $this->resolveAlias($id);
88 5
89
        if ($this->has($id)) {
90 5
            return $this->getInstance($id);
91 5
        }
92 5
93
        return $this->createInstance($id);
94 5
    }
95 5
96 5
    public function resolve(callable $callable): mixed
97 5
    {
98
        $callableKey = $this->callableKey($callable);
99
        $callable = Closure::fromCallable($callable);
100
101 5
        if (!isset($this->cachedDependencies[$callableKey])) {
102
            $this->cachedDependencies[$callableKey] = $this
103
                ->getDependencyResolver()
104 1
                ->resolveDependencies($callable);
105
        }
106 1
107
        /** @psalm-suppress MixedMethodCall */
108 1
        return $callable(...$this->cachedDependencies[$callableKey]);
109
    }
110
111 1
    public function factory(Closure $instance): Closure
112
    {
113 1
        $this->factoryInstances->attach($instance);
114 1
115 1
        return $instance;
116 1
    }
117
118
    public function remove(string $id): void
119
    {
120
        $id = $this->resolveAlias($id);
121
122 11
        unset(
123
            $this->instances[$id],
124 11
            $this->frozenInstances[$id],
125 4
        );
126
    }
127 4
128
    public function alias(string $alias, string $id): void
129
    {
130 10
        $this->aliases[$alias] = $id;
131 4
    }
132
133
    /**
134 8
     * @param class-string $className
135 1
     * @return list<string>
136
     */
137
    public function getDependencyTree(string $className): array
138 7
    {
139 7
        if (!class_exists($className)) {
140 6
            return [];
141
        }
142 6
143
        $dependencies = [];
144
        $this->collectDependencies($className, $dependencies);
145 2
146
        /** @var list<string> */
147 2
        return array_values(array_keys($dependencies));
148
    }
149 2
150
    /**
151
     * @psalm-suppress MixedAssignment
152 15
     */
153
    public function extend(string $id, Closure $instance): Closure
154 15
    {
155
        $id = $this->resolveAlias($id);
156 15
157 14
        if (!$this->has($id)) {
158 15
            $this->extendLater($id, $instance);
159
160 8
            return $instance;
161
        }
162
163 12
        if (isset($this->frozenInstances[$id])) {
164 1
            throw ContainerException::frozenInstanceExtend($id);
165
        }
166
167 11
        if (is_object($this->instances[$id]) && isset($this->protectedInstances[$this->instances[$id]])) {
168
            throw ContainerException::instanceProtected($id);
169
        }
170 11
171
        $factory = $this->instances[$id];
172 11
        $extended = $this->generateExtendedInstance($instance, $factory);
173
        $this->set($id, $extended);
174 11
175
        if (is_object($factory) && isset($this->factoryInstances[$factory])) {
176
            $this->factoryInstances->detach($factory);
177 18
            $this->factoryInstances->attach($extended);
178
        }
179 18
180 4
        return $extended;
181 4
    }
182
183 2
    public function protect(Closure $instance): Closure
184
    {
185 4
        $this->protectedInstances->attach($instance);
186 2
187
        return $instance;
188
    }
189
190 2
    /**
191 2
     * @return list<string>
192
     */
193
    public function getRegisteredServices(): array
194
    {
195 14
        return array_keys($this->instances);
196 10
    }
197
198
    public function isFactory(string $id): bool
199 4
    {
200
        $id = $this->resolveAlias($id);
201
202
        if (!$this->has($id)) {
203
            return false;
204
        }
205 12
206
        /** @var mixed $instance */
207 12
        $instance = $this->instances[$id];
208 12
        return is_object($instance) && isset($this->factoryInstances[$instance]);
209 12
    }
210 12
211 12
    public function isFrozen(string $id): bool
212
    {
213
        $id = $this->resolveAlias($id);
214
        return isset($this->frozenInstances[$id]);
215 12
    }
216
217
    /**
218
     * @return array<class-string, class-string|callable|object>
219
     */
220
    public function getBindings(): array
221 4
    {
222
        return $this->bindings;
223 4
    }
224
225
    /**
226 17
     * @param list<class-string> $classNames
227
     */
228 17
    public function warmUp(array $classNames): void
229 17
    {
230 17
        foreach ($classNames as $className) {
231 17
            if (!class_exists($className)) {
232
                continue;
233
            }
234 17
235
            // Pre-resolve dependencies to populate cache
236
            if (!isset($this->cachedDependencies[$className])) {
237
                $this->cachedDependencies[$className] = $this
238
                    ->getDependencyResolver()
239
                    ->resolveDependencies($className);
240 7
            }
241
        }
242 7
    }
243 5
244 5
    private function getInstance(string $id): mixed
245
    {
246 5
        $this->frozenInstances[$id] = true;
247 5
248
        if (!is_object($this->instances[$id])
249
            || isset($this->protectedInstances[$this->instances[$id]])
250 4
            || !method_exists($this->instances[$id], '__invoke')
251 3
        ) {
252
            return $this->instances[$id];
253
        }
254 1
255
        if (isset($this->factoryInstances[$this->instances[$id]])) {
256
            return $this->instances[$id]($this);
257 19
        }
258
259 19
        $rawService = $this->instances[$id];
260 16
261
        /** @var mixed $resolvedService */
262 3
        $resolvedService = $rawService($this);
263
264 3
        $this->instances[$id] = $resolvedService;
265 3
266
        return $resolvedService;
267
    }
268 3
269 3
    private function createInstance(string $class): ?object
270
    {
271
        if (isset($this->bindings[$class])) {
272
            $binding = $this->bindings[$class];
273
            if (is_callable($binding)) {
274
                /** @var mixed $binding */
275
                $binding = $binding();
276
            }
277
            if (is_object($binding)) {
278
                return $binding;
279
            }
280
281
            /** @var class-string $binding */
282
            if (class_exists($binding)) {
283
                return $this->instantiateClass($binding);
284
            }
285
        }
286
287
        if (class_exists($class)) {
288
            return $this->instantiateClass($class);
289
        }
290
291
        return null;
292
    }
293
294
    /**
295
     * @param  class-string  $class
296
     */
297
    private function instantiateClass(string $class): ?object
298
    {
299
        if (class_exists($class)) {
300
            if (!isset($this->cachedDependencies[$class])) {
301
                $this->cachedDependencies[$class] = $this
302
                    ->getDependencyResolver()
303
                    ->resolveDependencies($class);
304
            }
305
306
            /** @psalm-suppress MixedMethodCall */
307
            return new $class(...$this->cachedDependencies[$class]);
308
        }
309
310
        return null;
311
    }
312
313
    private function extendLater(string $id, Closure $instance): void
314
    {
315
        $this->instancesToExtend[$id][] = $instance;
316
    }
317
318
    private function getDependencyResolver(): DependencyResolver
319
    {
320
        if ($this->dependencyResolver === null) {
321
            $this->dependencyResolver = new DependencyResolver(
322
                $this->bindings,
323
            );
324
        }
325
326
        return $this->dependencyResolver;
327
    }
328
329
    /**
330
     * Generates a unique string key for a given callable.
331
     *
332
     * @psalm-suppress MixedReturnTypeCoercion
333
     */
334
    private function callableKey(callable $callable): string
335
    {
336
        if (is_array($callable)) {
337
            [$classOrObject, $method] = $callable;
338
339
            $className = is_object($classOrObject)
340
                ? get_class($classOrObject) . '#' . spl_object_id($classOrObject)
341
                : $classOrObject;
342
343
            return $className . '::' . $method;
344
        }
345
346
        if (is_string($callable)) {
347
            return $callable;
348
        }
349
350
        if ($callable instanceof Closure) {
351
            return spl_object_hash($callable);
352
        }
353
354
        // Invokable objects
355
        /** @psalm-suppress RedundantCondition */
356
        if (is_object($callable)) {
357
            return get_class($callable) . '#' . spl_object_id($callable);
358
        }
359
360
        // Fallback for edge cases
361
        /** @psalm-suppress MixedArgument */
362
        return 'callable:' . md5(serialize($callable));
363
    }
364
365
    /**
366
     * @psalm-suppress MissingClosureReturnType,MixedAssignment
367
     */
368
    private function generateExtendedInstance(Closure $instance, mixed $factory): Closure
369
    {
370
        if (is_callable($factory)) {
371
            return static function (self $container) use ($instance, $factory) {
372
                $result = $factory($container);
373
374
                return $instance($result, $container) ?? $result;
375
            };
376
        }
377
378
        if (is_object($factory) || is_array($factory)) {
379
            return static fn (self $container) => $instance($factory, $container) ?? $factory;
380
        }
381
382
        throw ContainerException::instanceNotExtendable();
383
    }
384
385
    private function extendService(string $id): void
386
    {
387
        if (!isset($this->instancesToExtend[$id]) || count($this->instancesToExtend[$id]) === 0) {
388
            return;
389
        }
390
        $this->currentlyExtending = $id;
391
392
        foreach ($this->instancesToExtend[$id] as $instance) {
393
            $this->extend($id, $instance);
394
        }
395
396
        unset($this->instancesToExtend[$id]);
397
        $this->currentlyExtending = null;
398
    }
399
400
    private function resolveAlias(string $id): string
401
    {
402
        return $this->aliases[$id] ?? $id;
403
    }
404
405
    /**
406
     * @param class-string $className
407
     * @param array<string, true> $dependencies
408
     */
409
    private function collectDependencies(string $className, array &$dependencies): void
410
    {
411
        $reflection = new \ReflectionClass($className);
412
413
        $constructor = $reflection->getConstructor();
414
        if ($constructor === null) {
415
            return;
416
        }
417
418
        foreach ($constructor->getParameters() as $parameter) {
419
            $type = $parameter->getType();
420
            if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) {
421
                continue;
422
            }
423
424
            $paramTypeName = $type->getName();
425
426
            // Resolve binding if it's an interface
427
            if (isset($this->bindings[$paramTypeName])) {
428
                $binding = $this->bindings[$paramTypeName];
429
                if (is_string($binding) && class_exists($binding)) {
430
                    /** @var class-string $paramTypeName */
431
                    $paramTypeName = $binding;
432
                }
433
            }
434
435
            if (isset($dependencies[$paramTypeName])) {
436
                continue; // Already processed
437
            }
438
439
            $dependencies[$paramTypeName] = true;
440
441
            if (class_exists($paramTypeName)) {
442
                $this->collectDependencies($paramTypeName, $dependencies);
443
            }
444
        }
445
    }
446
}
447