Skip to content

Commit 829cf1a

Browse files
committed
Implement ::extend() functionality
1 parent 3a1cd96 commit 829cf1a

3 files changed

Lines changed: 181 additions & 28 deletions

File tree

src/CascadeContainer.php

Lines changed: 85 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
namespace Technically\CascadeContainer;
44

5-
use Exceptions\ServiceNotFound;
65
use InvalidArgumentException;
76
use Psr\Container\ContainerInterface;
7+
use Technically\CascadeContainer\Exceptions\ServiceNotFound;
88
use Technically\DependencyResolver\Contracts\DependencyResolver as DependencyResolverInterface;
99
use Technically\DependencyResolver\DependencyResolver;
1010
use Technically\DependencyResolver\Exceptions\CannotAutowireArgument;
@@ -22,7 +22,10 @@ final class CascadeContainer implements ContainerInterface
2222
private array $instances = [];
2323

2424
/** @var array<string,callable> */
25-
private array $resolvers = [];
25+
private array $deferred = [];
26+
27+
/** @var array<string,callable> */
28+
private array $factories = [];
2629

2730
/** @var array<string,string> */
2831
private array $aliases = [];
@@ -56,8 +59,16 @@ public function get(string $id)
5659
return $this->instances[$id];
5760
}
5861

59-
if (array_key_exists($id, $this->resolvers)) {
60-
return $this->call($this->resolvers[$id]);
62+
if (array_key_exists($id, $this->deferred)) {
63+
$instance = $this->call($this->deferred[$id]);
64+
65+
$this->instances[$id] = $instance;
66+
67+
return $instance;
68+
}
69+
70+
if (array_key_exists($id, $this->factories)) {
71+
return $this->call($this->factories[$id]);
6172
}
6273

6374
if ($this->parent->has($id)) {
@@ -74,7 +85,8 @@ public function has(string $id): bool
7485
}
7586

7687
return array_key_exists($id, $this->instances)
77-
|| array_key_exists($id, $this->resolvers)
88+
|| array_key_exists($id, $this->deferred)
89+
|| array_key_exists($id, $this->factories)
7890
|| $this->parent->has($id);
7991
}
8092

@@ -87,14 +99,15 @@ public function has(string $id): bool
8799
*/
88100
public function set(string $id, mixed $instance): void
89101
{
90-
$this->instances[$id] = $instance;
102+
$this->forget($id);
91103

92-
unset($this->resolvers[$id]);
93-
unset($this->aliases[$id]);
104+
$this->instances[$id] = $instance;
94105
}
95106

96107
public function alias(string $id, string $alias): void
97108
{
109+
$this->forget($alias);
110+
98111
if ($id === $alias) {
99112
throw new InvalidArgumentException('Cannot alias a service to itself.');
100113
}
@@ -117,9 +130,9 @@ public function alias(string $id, string $alias): void
117130
*/
118131
public function factory(string $id, callable $constructor): void
119132
{
120-
$this->resolvers[$id] = $constructor;
133+
$this->forget($id);
121134

122-
unset($this->aliases[$id]);
135+
$this->factories[$id] = $constructor;
123136
}
124137

125138
/**
@@ -134,15 +147,9 @@ public function factory(string $id, callable $constructor): void
134147
*/
135148
public function deferred(string $id, callable $resolver): void
136149
{
137-
$this->resolvers[$id] = function () use ($id, $resolver): mixed {
138-
$instance = $this->call($resolver);
150+
$this->forget($id);
139151

140-
$this->set($id, $instance);
141-
142-
return $instance;
143-
};
144-
145-
unset($this->aliases[$id]);
152+
$this->deferred[$id] = $resolver;
146153
}
147154

148155
/**
@@ -165,6 +172,55 @@ public function resolve(string $id): mixed
165172
return $this->resolver->resolve($id);
166173
}
167174

175+
/**
176+
* Extend the existing service by applying the callback function to it.
177+
*
178+
* - Whatever the callback function returns will replace the previous instance.
179+
* - If the service being extended is defined via a deferred resolver, the extension will become a deferred resolver too.
180+
* - If the service being extended is defined as a factory, the extension will become a factory too.
181+
*
182+
* @param string $id
183+
* @param callable $extension
184+
* @return void
185+
*/
186+
public function extend(string $id, callable $extension): void
187+
{
188+
if (array_key_exists($id, $this->aliases)) {
189+
$this->extend($this->aliases[$id], $extension);
190+
return;
191+
}
192+
193+
if (array_key_exists($id, $this->instances)) {
194+
$this->instances[$id] = $this->call($extension, [$this->instances[$id]]);
195+
return;
196+
}
197+
198+
if (array_key_exists($id, $this->deferred)) {
199+
$resolver = $this->deferred[$id];
200+
$this->deferred[$id] = function () use ($extension, $resolver) {
201+
return $this->call($extension, [$this->call($resolver)]);
202+
};
203+
return;
204+
}
205+
206+
if (array_key_exists($id, $this->factories)) {
207+
$factory = $this->factories[$id];
208+
$this->factories[$id] = function () use ($extension, $factory) {
209+
return $this->call($extension, [$this->call($factory)]);
210+
};
211+
return;
212+
}
213+
214+
if ($this->parent->has($id)) {
215+
$this->deferred[$id] = function () use ($extension, $id) {
216+
return $this->call($extension, [$this->parent->get($id)]);
217+
};
218+
return;
219+
}
220+
221+
throw new ServiceNotFound($id);
222+
}
223+
168224
/**
169225
* Force-construct the given class instance using container state for auto-wiring dependencies.
170226
*
@@ -197,4 +253,15 @@ public function call(callable $callable, array $bindings = []): mixed
197253
{
198254
return $this->resolver->call($callable, $bindings);
199255
}
256+
257+
/**
258+
* Erase any definitions for the given service.
259+
*/
260+
private function forget(string $id): void
261+
{
262+
unset($this->aliases[$id]);
263+
unset($this->instances[$id]);
264+
unset($this->deferred[$id]);
265+
unset($this->factories[$id]);
266+
}
200267
}

src/Exceptions/ServiceNotFound.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22

3-
namespace Exceptions;
3+
namespace Technically\CascadeContainer\Exceptions;
44

55
use Psr\Container\NotFoundExceptionInterface;
66
use RuntimeException;
@@ -11,6 +11,8 @@ class ServiceNotFound extends RuntimeException implements NotFoundExceptionInter
1111

1212
public function __construct(string $serviceName)
1313
{
14+
$this->serviceName = $serviceName;
15+
1416
parent::__construct("Service `{$serviceName}` is not defined in the container.");
1517
}
1618

tests/Feature/CascadeContainerTest.php

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,12 @@ public function call(callable $callable, array $bindings = []): mixed
218218
expect($container->get('date') === $container->get('date'))->toBeTrue(); // The same instance is returned every time
219219
});
220220

221-
it('should not override previously defined instances', function () {
221+
it('should override previously defined instances', function () {
222222
$container = new CascadeContainer();
223223
$container->set('container', $container);
224224
$container->deferred('container', fn () => new NullContainer());
225225

226-
expect($container->get('container'))->toBe($container);
226+
expect($container->get('container'))->toBeInstanceOf(NullContainer::class);
227227
});
228228

229229
it('should override previously defined factories', function () {
@@ -243,14 +243,14 @@ public function call(callable $callable, array $bindings = []): mixed
243243
expect($container->get('container'))->toBe($container);
244244
});
245245

246-
it('it should not override previously defined resolved deferred resolvers', function () {
246+
it('it should override previously defined resolved deferred resolvers', function () {
247247
$container = new CascadeContainer();
248248

249249
$container->deferred('container', fn () => new NullContainer());
250250
expect($container->get('container'))->toBeInstanceOf(NullContainer::class);
251251

252252
$container->deferred('container', fn () => $container);
253-
expect($container->get('container'))->toBeInstanceOf(NullContainer::class);
253+
expect($container->get('container'))->toBeInstanceOf(CascadeContainer::class);
254254
});
255255

256256
it('should take precedence over parent container', function () {
@@ -289,12 +289,12 @@ public function call(callable $callable, array $bindings = []): mixed
289289
expect($container->get('date') === $container->get('date'))->toBeFalse(); // A new instance is returned every time
290290
});
291291

292-
it('should not override previously defined instances', function () {
292+
it('should override previously defined instances', function () {
293293
$container = new CascadeContainer();
294294
$container->set('container', $container);
295295
$container->factory('container', fn () => new NullContainer());
296296

297-
expect($container->get('container'))->toBe($container);
297+
expect($container->get('container'))->toBeInstanceOf(NullContainer::class);
298298
});
299299

300300
it('should override previously defined factories', function () {
@@ -313,15 +313,14 @@ public function call(callable $callable, array $bindings = []): mixed
313313
expect($container->get('container'))->toBe($container);
314314
});
315315

316-
it('it should not override previously defined resolved deferred resolvers', function () {
316+
it('it should override previously defined resolved deferred resolvers', function () {
317317
$container = new CascadeContainer();
318318

319319
$container->deferred('container', fn () => new NullContainer());
320320
expect($container->get('container'))->toBeInstanceOf(NullContainer::class);
321321

322322
$container->factory('container', fn () => $container);
323-
324-
expect($container->get('container'))->toBeInstanceOf(NullContainer::class);
323+
expect($container->get('container'))->toBe($container);
325324
});
326325

327326
it('should take precedence over parent container', function () {
@@ -351,6 +350,91 @@ public function call(callable $callable, array $bindings = []): mixed
351350
});
352351
});
353352

353+
describe('CascadeContainer::extend()', function () {
354+
it('should extend the existing service instance', function () {
355+
$container = new CascadeContainer();
356+
$container->set('container', new NullContainer());
357+
358+
expect($container->get('container'))->toBeInstanceOf(NullContainer::class);
359+
360+
$container->extend('container', function ($container) {
361+
expect($container)->toBeInstanceOf(NullContainer::class);
362+
363+
return new CascadeContainer(parent: $container);
364+
});
365+
366+
expect($container->get('container'))->toBeInstanceOf(CascadeContainer::class);
367+
});
368+
369+
it('should extend deferred service resolvers', function () {
370+
$container = new CascadeContainer();
371+
$container->deferred('container', fn () => new NullContainer());
372+
373+
$container->extend('container', function ($container) {
374+
expect($container)->toBeInstanceOf(NullContainer::class);
375+
376+
return new CascadeContainer(parent: $container);
377+
});
378+
379+
expect($container->get('container'))->toBeInstanceOf(CascadeContainer::class);
380+
});
381+
382+
it('should extend service factories', function () {
383+
$container = new CascadeContainer();
384+
385+
$container->factory('date', fn () => new DateTime('now'));
386+
387+
$container->extend('date', function ($date) {
388+
expect($date)->toBeInstanceOf(DateTime::class);
389+
390+
$date->setTimezone(new DateTimeZone('Europe/Madrid'));
391+
392+
return $date;
393+
});
394+
395+
expect($container->get('date'))->toBeInstanceOf(DateTime::class);
396+
expect($container->get('date')->getTimeZone())->toEqual(new DateTimeZone('Europe/Madrid'));
397+
398+
expect($container->get('date') !== $container->get('date'))->toBeTrue();
399+
});
400+
401+
it('should extend services by their aliases', function () {
402+
$container = new CascadeContainer();
403+
404+
$container->set('container', $container);
405+
$container->alias('container', alias: ContainerInterface::class);
406+
407+
$container->extend(ContainerInterface::class, function ($container) {
408+
expect($container)->toBeInstanceOf(CascadeContainer::class);
409+
410+
assert($container instanceof CascadeContainer);
411+
412+
return $container->cascade();
413+
});
414+
415+
expect($container->get('container'))->toBeInstanceOf(CascadeContainer::class);
416+
expect($container->get(ContainerInterface::class))->toBeInstanceOf(CascadeContainer::class);
417+
418+
expect($container->get('container') === $container->get(ContainerInterface::class))->toBeTrue();
419+
expect($container->get('container') === $container)->toBeFalse();
420+
});
421+
422+
it('it should call a callback function auto-wiring its arguments', function () {
423+
$container = new CascadeContainer();
424+
$container->deferred(DateTime::class, fn () => new DateTime('2025-01-01T12:00:00Z'));
425+
$container->factory(ContainerInterface::class, fn () => new NullContainer());
426+
427+
$container->extend(ContainerInterface::class, function (ContainerInterface $container, DateTime $date) {
428+
expect($date)->toEqual(new DateTime('2025-01-01T12:00:00Z'));
429+
expect($container)->toBeInstanceOf(NullContainer::class);
430+
431+
return new ArrayContainer();
432+
});
433+
434+
expect($container->get(ContainerInterface::class))->toBeInstanceOf(ArrayContainer::class);
435+
});
436+
});
437+
354438
describe('CascadeContainer::resolve()', function () {
355439
it('it should get an instance from the container when defined', function () {
356440
$container = new CascadeContainer();

0 commit comments

Comments
 (0)