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