diff --git a/src/Phaseolies/DI/Container.php b/src/Phaseolies/DI/Container.php index b9cb72e0..ea3167c3 100644 --- a/src/Phaseolies/DI/Container.php +++ b/src/Phaseolies/DI/Container.php @@ -570,6 +570,54 @@ public function extend(string $abstract, callable $extender): void ]; } + /** + * Run a callback with some bindings temporarily replaced, then put every one + * of them back exactly as it was, even when the callback throws + * + * @param array $overrides + * @param callable(self): mixed $callback + * @return mixed + */ + public function using(array $overrides, callable $callback): mixed + { + $saved = []; + + foreach (array_keys($overrides) as $abstract) { + $saved[$abstract] = [ + 'binding' => array_key_exists($abstract, $this->bindings) ? $this->bindings[$abstract] : null, + 'hasBinding' => array_key_exists($abstract, $this->bindings), + 'instance' => $this->instances[$abstract] ?? null, + 'hasInstance' => array_key_exists($abstract, $this->instances), + ]; + } + + try { + foreach ($overrides as $abstract => $concrete) { + unset($this->instances[$abstract]); + + if ($concrete instanceof \Closure || (is_string($concrete) && class_exists($concrete))) { + $this->bind($abstract, $concrete, true); + } else { + $this->instance($abstract, $concrete); + } + } + + return $callback($this); + } finally { + foreach ($saved as $abstract => $state) { + unset($this->bindings[$abstract], $this->instances[$abstract]); + + if ($state['hasBinding']) { + $this->bindings[$abstract] = $state['binding']; + } + + if ($state['hasInstance']) { + $this->instances[$abstract] = $state['instance']; + } + } + } + } + /** * Alias a type to a different name * diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index 6a1a4081..031e29e1 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -3021,6 +3021,95 @@ public function testMultipleImmutableServicesGuardedIndependently() $this->assertTrue($mailerThrew); } + public function testUsingOverridesForTheCallbackAndRestoresAfterwards() + { + $this->container->instance('mailer', 'real'); + + $inside = $this->container->using(['mailer' => 'fake'], fn(Container $c) => $c->make('mailer')); + + $this->assertSame('fake', $inside); + $this->assertSame('real', $this->container->make('mailer')); + } + + public function testUsingRemovesBindingsThatDidNotExistBefore() + { + $this->container->using(['scratch' => 'value'], function (Container $c) { + $this->assertTrue($c->hasInstance('scratch')); + }); + + $this->assertFalse($this->container->hasInstance('scratch')); + $this->assertArrayNotHasKey('scratch', $this->container->getBindings()); + $this->assertArrayNotHasKey('scratch', $this->container->getInstances()); + } + + public function testUsingRestoresEvenWhenTheCallbackThrows() + { + $this->container->instance('mailer', 'real'); + + try { + $this->container->using(['mailer' => 'fake'], function () { + throw new RuntimeException('boom'); + }); + $this->fail('Expected the exception to propagate'); + } catch (RuntimeException $e) { + $this->assertSame('boom', $e->getMessage()); + } + + $this->assertSame('real', $this->container->make('mailer')); + } + + public function testUsingClosureIsAFactoryCalledOnceForTheScope() + { + $calls = 0; + + $this->container->using(['token' => function () use (&$calls) { + $calls++; + + return new \stdClass(); + }], function (Container $c) { + $this->assertSame($c->make('token'), $c->make('token')); + }); + + $this->assertSame(1, $calls); + } + + public function testUsingBuildsAClassNameOverride() + { + $this->container->bind(TestInterface::class, AnotherImplementation::class); + + $this->container->using([TestInterface::class => ConcreteImplementation::class], function (Container $c) { + $this->assertInstanceOf(ConcreteImplementation::class, $c->make(TestInterface::class)); + }); + + $this->assertInstanceOf(AnotherImplementation::class, $this->container->make(TestInterface::class)); + } + + public function testUsingRestoresTheSameSingletonInstanceThatWasResolvedBefore() + { + $this->container->singleton(Counter::class); + $before = $this->container->make(Counter::class); + + $this->container->using([Counter::class => new Counter()], function (Container $c) use ($before) { + $this->assertNotSame($before, $c->make(Counter::class)); + }); + + $this->assertSame($before, $this->container->make(Counter::class)); + } + + public function testUsingCanBeNested() + { + $this->container->instance('level', 'outer'); + + $result = $this->container->using(['level' => 'one'], function (Container $c) { + $c->using(['level' => 'two'], fn() => $this->assertSame('two', $c->make('level'))); + + return $c->make('level'); + }); + + $this->assertSame('one', $result); + $this->assertSame('outer', $this->container->make('level')); + } + public function testMailerServiceReadsWorkAfterFreeze() { $mailer = $this->container->make(MockMailerService::class);