Container::resolve()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

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