diff --git a/Tests/ContainerResourceTest.php b/Tests/ContainerResourceTest.php index e35604b4..3f8c6816 100644 --- a/Tests/ContainerResourceTest.php +++ b/Tests/ContainerResourceTest.php @@ -183,6 +183,87 @@ public function testGetInstanceWithInstanceInNonSharedMode() $this->assertNotSame($stub, $resource->getInstance()); } + /** + * @testdox If resource is lazy, a lazy proxy object is returned + * + * @covers Joomla\DI\Container + * @uses Joomla\DI\ContainerResource + */ + public function testGetInstanceInLazyMode() + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Lazy objects are only supported in PHP 8.4 or newer.'); + } + + $container = new Container(); + $container->set('stub1', fn() => new Stub1(), true, true); + + $factoryCalled = false; + + $resource = new ContainerResource( + $container, + static function($container) use (&$factoryCalled) { + $factoryCalled = true; + return new Stub2($container->get('stub1')); + }, + ContainerResource::LAZY, + Stub2::class + ); + + $stub2 = $resource->getInstance(false); + + $this->assertTrue( + (new \ReflectionClass(Stub2::class))->isUninitializedLazyObject($stub2), + 'Lazy proxy object should be returned' + ); + $this->assertFalse( + $factoryCalled, + 'Factory should not be called before object state is observed or modified' + ); + $this->assertSame( + $container->get('stub1'), + $stub2->stub, + 'Factory should be called when object state is observed or modified' + ); + } + + /** + * @testdox If resource is lazy, but lazy objects are not supported, a normal object is returned + * + * @covers Joomla\DI\Container + * @uses Joomla\DI\ContainerResource + */ + public function testGetInstanceInLazyModeNotSupported() + { + if (PHP_VERSION_ID >= 80400) { + $this->markTestSkipped(); + } + + $container = new Container(); + $container->set('stub1', fn() => new Stub1(), true, true); + + $resource = new ContainerResource( + $container, + static function($container) { + return new Stub2($container->get('stub1')); + }, + ContainerResource::LAZY, + Stub2::class + ); + + $stub2 = $resource->getInstance(false); + + ob_start(); + var_dump($stub2); + $type = ob_get_clean(); + + $this->assertStringStartsWith( + 'object(' . Stub2::class, + $type, + 'Normal object should be returned' + ); + } + /** * @testdox After a reset, a new instance is returned even for shared resources with factories * diff --git a/Tests/ContainerSetupTest.php b/Tests/ContainerSetupTest.php index bf455d74..2fc2c346 100644 --- a/Tests/ContainerSetupTest.php +++ b/Tests/ContainerSetupTest.php @@ -274,6 +274,46 @@ static function () { $this->assertTrue($container->isProtected('foo')); } + /** + * @testdox The convenience method lazy() sets resources as lazy, but not protected and shared by default + * + * @covers Joomla\DI\Container + * @uses Joomla\DI\ContainerResource + */ + public function testLazy() + { + $container = new Container(); + $container->lazy( + \stdClass::class, + static function () { + return new \stdClass(); + }, + ); + + $this->assertFalse($container->isShared(\stdClass::class)); + $this->assertFalse($container->isProtected(\stdClass::class)); + } + + /** + * @testdox The convenience method lazy() throws an exception when lazy class does not exist + * + * @covers Joomla\DI\Container + * @uses Joomla\DI\ContainerResource + */ + public function testLazyClassNotExists() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Lazy key must be a valid class name: "ThisClassDoesNotExist"'); + + $container = new Container(); + $container->lazy( + 'ThisClassDoesNotExist', + static function () { + return new \stdClass(); + }, + ); + } + /** * @testdox The callback gets the container instance as a parameter * diff --git a/Tests/ResourceDecorationTest.php b/Tests/ResourceDecorationTest.php index c8789228..7bb8ff40 100644 --- a/Tests/ResourceDecorationTest.php +++ b/Tests/ResourceDecorationTest.php @@ -154,6 +154,63 @@ static function ($shared) { ); } + /** + * @testdox A lazy resource can be extended + * + * @covers Joomla\DI\Container + * @uses Joomla\DI\ContainerResource + */ + public function testExtendLazy() + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Lazy objects are only supported in PHP 8.4 or newer.'); + } + + $factoryCalled = false; + $extendCalled = false; + + $container = new Container(); + $container->lazy( + Stub2::class, + static function () use (&$factoryCalled) { + $factoryCalled = true; + + return new Stub2(new Stub1()); + } + ); + + $container->extend( + Stub2::class, + static function ($lazy) use (&$extendCalled) { + $extendCalled = true; + + $lazy->stub = 'stub1'; + + return $lazy; + } + ); + + $stub2 = $container->get(Stub2::class); + + $this->assertTrue( + (new \ReflectionClass(Stub2::class))->isUninitializedLazyObject($stub2), + 'Lazy proxy object should be returned' + ); + $this->assertFalse( + $factoryCalled, + 'Factory should not be called before object state is observed or modified' + ); + $this->assertFalse( + $extendCalled, + 'Extend callable should not be called before object state is observed or modified' + ); + $this->assertSame( + 'stub1', + $stub2->stub, + 'Extend callable should be called after the factory' + ); + } + /** * A base method defining a resource in a container * diff --git a/src/Container.php b/src/Container.php index 514ded68..d32e45a0 100644 --- a/src/Container.php +++ b/src/Container.php @@ -92,7 +92,7 @@ public function get($resourceName) throw new KeyNotFoundException(sprintf("Resource '%s' has not been registered with the container.", $resourceName)); } - return $this->resources[$key]->getInstance(); + return $this->resources[$key]->getInstance(false); } /** @@ -435,10 +435,14 @@ public function extend($resourceName, callable $callable) $resource = $this->getResource($key, true); $closure = function ($c) use ($callable, $resource) { - return $callable($resource->getInstance(), $c); + return $callable($resource->getInstance(true), $c); }; - $this->set($key, $closure, $resource->isShared()); + if ($resource->isLazy()) { + $this->lazy($key, $closure, $resource->isShared(), false); + } else { + $this->set($key, $closure, $resource->isShared(), false); + } } /** @@ -665,6 +669,37 @@ public function share($key, $value, $protected = false) return $this->set($key, $value, true, $protected); } + /** + * Shortcut method for creating lazy keys. + * + * @param string $key Name of dataStore key to set. + * @param mixed $value Callable function to run or string to retrieve when requesting the specified $key. + * @param boolean $shared True to create and store a shared instance. + * @param boolean $protected True to protect this item from being overwritten. Useful for services. + * + * @return $this + * + * @since __DEPLOY_VERSION__ + */ + public function lazy($key, $value, $shared = false, $protected = false): self + { + if ($this->has($key) && $this->isLocal($key) && $this->isProtected($key)) { + throw new ProtectedKeyException(sprintf("Key %s is protected and can't be overwritten.", $key)); + } + + if (!class_exists($key)) { + throw new \InvalidArgumentException(sprintf('Lazy key must be a valid class name: "%s".', $key)); + } + + $mode = ContainerResource::LAZY; + $mode |= $shared ? ContainerResource::SHARE : ContainerResource::NO_SHARE; + $mode |= $protected ? ContainerResource::PROTECT : ContainerResource::NO_PROTECT; + + $this->resources[$key] = new ContainerResource($this, $value, $mode, $key); + + return $this; + } + /** * Get the raw data assigned to a key. * diff --git a/src/ContainerResource.php b/src/ContainerResource.php index 26e6e80f..9da4d0ad 100644 --- a/src/ContainerResource.php +++ b/src/ContainerResource.php @@ -49,6 +49,22 @@ final class ContainerResource */ public const PROTECT = 2; + /** + * Defines the resource as non-lazy + * + * @const integer + * @since __DEPLOY_VERSION__ + */ + public const NO_LAZY = 0; + + /** + * Defines the resource as lazy + * + * @const integer + * @since __DEPLOY_VERSION__ + */ + public const LAZY = 4; + /** * The container the resource is assigned to * @@ -73,6 +89,14 @@ final class ContainerResource */ private $factory; + /** + * The lazy factory object + * + * @var callable + * @since __DEPLOY_VERSION__ + */ + private $lazyFactory; + /** * Flag if the resource is shared * @@ -89,23 +113,36 @@ final class ContainerResource */ private $protected = false; + /** + * Flag if the resource is lazy + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + private $lazy = false; + /** * Create a resource representation * * @param Container $container The container * @param mixed $value The resource or its factory closure * @param integer $mode Resource mode, defaults to Resource::NO_SHARE | Resource::NO_PROTECT + * @param string $key Service key * * @since 2.0.0 */ - public function __construct(Container $container, $value, int $mode = 0) + public function __construct(Container $container, $value, int $mode = 0, string $key = '') { $this->container = $container; $this->shared = ($mode & self::SHARE) === self::SHARE; $this->protected = ($mode & self::PROTECT) === self::PROTECT; + $this->lazy = ($mode & self::LAZY) === self::LAZY; if (\is_callable($value)) { $this->factory = $value; + if ($this->lazy) { + $this->lazyFactory = $this->makeLazyFactory($key, $value); + } } else { if ($this->shared) { $this->instance = $value; @@ -147,19 +184,33 @@ public function isProtected(): bool return $this->protected; } + /** + * Check whether the resource is lazy + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + public function isLazy(): bool + { + return $this->lazy; + } + /** * Get an instance of the resource * * If a factory was provided, the resource is created and - if it is a shared resource - cached internally. * If the resource was provided directly, that resource is returned. * + * @param boolean $noLazy Not to use the lazy proxy + * * @return mixed * * @since 2.0.0 */ - public function getInstance() + public function getInstance(bool $noLazy = true) { - $callable = $this->factory; + $callable = $noLazy ? $this->factory : $this->lazyFactory ?? $this->factory; if ($this->isShared()) { if ($this->instance === null) { @@ -204,4 +255,25 @@ public function reset(): bool return false; } + + /** + * Create lazy proxy factory. + * + * @param string $class Fully qualified class name. + * @param callable $factory Factory to create lazy proxies for. + * + * @return callable + * + * @since __DEPLOY_VERSION__ + */ + private function makeLazyFactory(string $class, callable $factory): callable + { + if (PHP_VERSION_ID < 80400) { + return $factory; + } + + return function () use ($class, $factory) { + return (new \ReflectionClass($class))->newLazyProxy(fn() => $factory($this->container)); + }; + } }