diff --git a/composer.json b/composer.json index 34a41b87..ce715e21 100644 --- a/composer.json +++ b/composer.json @@ -25,11 +25,12 @@ "php": "^8.0", "ext-mbstring": "*", "psr/container": "^1.1|^2.0", - "yiisoft/definitions": "^3.0" + "yiisoft/definitions": "dev-lazy-definition" }, "require-dev": { "league/container": "^4.2", "maglnet/composer-require-checker": "^4.2", + "friendsofphp/proxy-manager-lts": "^1.0", "phpbench/phpbench": "^1.2.0", "phpunit/phpunit": "^9.5", "rector/rector": "^1.0.0", @@ -41,7 +42,8 @@ }, "suggest": { "yiisoft/injector": "^1.0", - "phpbench/phpbench": "To run benchmarks." + "phpbench/phpbench": "To run benchmarks.", + "friendsofphp/proxy-manager-lts": "Install this package if you want to use lazy loading." }, "provide": { "psr/container-implementation": "1.0.0" diff --git a/psalm.xml b/psalm.xml index 5ce79f0e..7d4b7337 100644 --- a/psalm.xml +++ b/psalm.xml @@ -14,6 +14,7 @@ + diff --git a/src/Container.php b/src/Container.php index f29df055..e30f2544 100644 --- a/src/Container.php +++ b/src/Container.php @@ -10,11 +10,13 @@ use Psr\Container\NotFoundExceptionInterface; use Throwable; use Yiisoft\Definitions\ArrayDefinition; +use Yiisoft\Definitions\Contract\DefinitionInterface; use Yiisoft\Definitions\DefinitionStorage; use Yiisoft\Definitions\Exception\CircularReferenceException; use Yiisoft\Definitions\Exception\InvalidConfigException; use Yiisoft\Definitions\Exception\NotInstantiableException; use Yiisoft\Definitions\Helpers\DefinitionValidator; +use Yiisoft\Definitions\LazyDefinition; use Yiisoft\Di\Helpers\DefinitionNormalizer; use Yiisoft\Di\Helpers\DefinitionParser; use Yiisoft\Di\Reference\TagReference; @@ -30,12 +32,15 @@ /** * Container implements a [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) container. + * + * @psalm-import-type MethodOrPropertyItem from ArrayDefinition */ final class Container implements ContainerInterface { private const META_TAGS = 'tags'; private const META_RESET = 'reset'; - private const ALLOWED_META = [self::META_TAGS, self::META_RESET]; + private const META_LAZY = 'lazy'; + private const ALLOWED_META = [self::META_TAGS, self::META_RESET, self::META_LAZY]; /** * @var DefinitionStorage Storage of object definitions. @@ -199,14 +204,13 @@ public function get(string $id) */ private function addDefinition(string $id, mixed $definition): void { - /** @var mixed $definition */ [$definition, $meta] = DefinitionParser::parse($definition); if ($this->validate) { $this->validateDefinition($definition, $id); $this->validateMeta($meta); } /** - * @psalm-var array{reset?:Closure,tags?:string[]} $meta + * @psalm-var array{reset?:Closure,lazy?:bool,tags?:string[]} $meta */ if (isset($meta[self::META_TAGS])) { @@ -215,6 +219,9 @@ private function addDefinition(string $id, mixed $definition): void if (isset($meta[self::META_RESET])) { $this->setDefinitionResetter($id, $meta[self::META_RESET]); } + if (isset($meta[self::META_LAZY]) && $meta[self::META_LAZY] === true) { + $definition = $this->decorateLazy($id, $definition); + } unset($this->instances[$id]); $this->addDefinitionToStorage($id, $definition); @@ -621,4 +628,52 @@ private function buildProvider(mixed $provider): ServiceProviderInterface return $providerInstance; } + + private function decorateLazy(string $id, mixed $definition): DefinitionInterface + { + $class = class_exists($id) || interface_exists($id) ? $id : null; + + if (is_array($definition) && isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) { + /** + * @psalm-var array{ + * class: class-string|null, + * '__construct()': array, + * methodsAndProperties: array + * } $definition + */ + if (empty($class)) { + $class = $definition[ArrayDefinition::CLASS_NAME]; + } + $this->checkClassOnNullForLazyService($class, $id, $definition); + $preparedDefinition = ArrayDefinition::fromPreparedData( + $definition[ArrayDefinition::CLASS_NAME] ?? $class, + $definition[ArrayDefinition::CONSTRUCTOR], + $definition['methodsAndProperties'], + ); + } else { + $this->checkClassOnNullForLazyService($class, $id, $definition); + $preparedDefinition = $definition; + } + + + + return new LazyDefinition($preparedDefinition, $class); + } + + /** + * @psalm-param class-string|null $class + * @psalm-assert class-string $class + */ + private function checkClassOnNullForLazyService(?string $class, string $id, mixed $definition): void + { + if (empty($class)) { + throw new InvalidConfigException( + sprintf( + 'Invalid definition: lazy services are only available with array definitions or references. Got type "%s" for definition ID: "%s"', + get_debug_type($definition), + $id, + ) + ); + } + } } diff --git a/tests/Unit/Helpers/DefinitionParserTest.php b/tests/Unit/Helpers/DefinitionParserTest.php index 6f0e4529..e3faa9a9 100644 --- a/tests/Unit/Helpers/DefinitionParserTest.php +++ b/tests/Unit/Helpers/DefinitionParserTest.php @@ -17,10 +17,11 @@ public function testParseCallableDefinition(): void $definition = [ 'definition' => $fn, 'tags' => ['one', 'two'], + 'lazy' => true, ]; [$definition, $meta] = DefinitionParser::parse($definition); $this->assertSame($fn, $definition); - $this->assertSame(['tags' => ['one', 'two']], $meta); + $this->assertSame(['tags' => ['one', 'two'], 'lazy' => true], $meta); } public function testParseArrayCallableDefinition(): void @@ -28,10 +29,11 @@ public function testParseArrayCallableDefinition(): void $definition = [ 'definition' => [StaticFactory::class, 'create'], 'tags' => ['one', 'two'], + 'lazy' => true, ]; [$definition, $meta] = DefinitionParser::parse($definition); $this->assertSame([StaticFactory::class, 'create'], $definition); - $this->assertSame(['tags' => ['one', 'two']], $meta); + $this->assertSame(['tags' => ['one', 'two'], 'lazy' => true], $meta); } public function testParseArrayDefinition(): void @@ -40,6 +42,7 @@ public function testParseArrayDefinition(): void 'class' => EngineMarkOne::class, '__construct()' => [42], 'tags' => ['one', 'two'], + 'lazy' => true, ]; [$definition, $meta] = DefinitionParser::parse($definition); $this->assertSame([ @@ -48,6 +51,6 @@ public function testParseArrayDefinition(): void 'methodsAndProperties' => [], DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA => true, ], $definition); - $this->assertSame(['tags' => ['one', 'two']], $meta); + $this->assertSame(['tags' => ['one', 'two'], 'lazy' => true], $meta); } } diff --git a/tests/Unit/LazyServiceContainerTest.php b/tests/Unit/LazyServiceContainerTest.php new file mode 100644 index 00000000..ab092e6e --- /dev/null +++ b/tests/Unit/LazyServiceContainerTest.php @@ -0,0 +1,120 @@ +markTestSkipped( + 'You should install `friendsofphp/proxy-manager-lts` if you want to use lazy services.' + ); + } + } + + public function testIsTheSameObject(): void + { + $class = EngineMarkOne::class; + $number = 55; + + $config = ContainerConfig::create() + ->withDefinitions([ + EngineMarkOne::class => [ + 'class' => $class, + 'setNumber()' => [$number], + 'lazy' => true, + ], + ]); + $container = new Container($config); + + /* @var EngineMarkOne $object */ + $object = $container->get($class); + + self::assertInstanceOf(LazyLoadingInterface::class, $object); + self::assertInstanceOf(EngineMarkOne::class, $object); + self::assertFalse($object->isProxyInitialized()); + self::assertEquals($number, $object->getNumber()); + self::assertTrue($object->isProxyInitialized()); + + /* @var EngineMarkOne $object */ + $object = $container->get($class); + + self::assertInstanceOf(LazyLoadingInterface::class, $object); + self::assertInstanceOf(EngineMarkOne::class, $object); + self::assertTrue($object->isProxyInitialized()); + } + + /** + * @dataProvider lazyDefinitionDataProvider + */ + public function testLazy(array $definitions, string $id): void + { + $config = ContainerConfig::create() + ->withDefinitions($definitions); + $container = new Container($config); + + $object = $container->get($id); + + self::assertInstanceOf(LazyLoadingInterface::class, $object); + } + + public function lazyDefinitionDataProvider(): array + { + return [ + 'class as definition name' => [ + [ + EngineMarkOne::class => [ + 'lazy' => true, + ], + ], + EngineMarkOne::class, + ], + 'class as key' => [ + [ + EngineMarkOne::class => [ + 'class' => EngineMarkOne::class, + 'lazy' => true, + ], + ], + EngineMarkOne::class, + ], + 'alias as key' => [ + [ + 'mark_one' => [ + 'class' => EngineMarkOne::class, + 'lazy' => true, + ], + ], + 'mark_one', + ], + 'dedicated array definition' => [ + [ + EngineMarkOne::class => [ + 'definition' => ['class' => EngineMarkOne::class], + 'lazy' => true, + ], + ], + EngineMarkOne::class, + ], + 'dedicated callback definition' => [ + [ + EngineMarkOne::class => [ + 'definition' => fn () => new EngineMarkOne(), + 'lazy' => true, + ], + ], + EngineMarkOne::class, + ], + ]; + } +}