diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e461ee4..19e5b358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 1.4.2 under development +- Enh #400: Improve container performance and make `has()` cache limit configurable (@samdark) - Enh #397: Explicitly import functions in "use" section (@mspirkov) ## 1.4.1 December 01, 2025 diff --git a/README.md b/README.md index 5ec3c586..705a371b 100644 --- a/README.md +++ b/README.md @@ -617,6 +617,20 @@ $config = ContainerConfig::create() $container = new Container($config); ``` +The container caches `has()` results to speed up repeated lookups. The cache is limited to 1024 entries by default. +If your application checks many dynamic service IDs in a long-running process, you can adjust the limit or disable the +cache: + +```php +use Yiisoft\Di\Container; +use Yiisoft\Di\ContainerConfig; + +$config = ContainerConfig::create() + ->withHasCacheLimit(0); // Disable `has()` cache. + +$container = new Container($config); +``` + ## Strict mode Container may work in a strict mode, that's when you should define everything in the container explicitly. diff --git a/src/CompositeContainer.php b/src/CompositeContainer.php index 7450d4fe..9176326a 100644 --- a/src/CompositeContainer.php +++ b/src/CompositeContainer.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; use RuntimeException; use Throwable; use Yiisoft\Di\Reference\TagReference; @@ -25,6 +26,13 @@ final class CompositeContainer implements ContainerInterface */ private array $containers = []; + /** + * Index of a container where a service ID was previously found. + * + * @psalm-var array + */ + private array $lookupCache = []; + /** * @psalm-template T * @psalm-param string|class-string $id @@ -71,8 +79,22 @@ public function get($id) return array_merge(...$tags); } - foreach ($this->containers as $container) { + if (isset($this->lookupCache[$id], $this->containers[$this->lookupCache[$id]])) { + $index = $this->lookupCache[$id]; + $container = $this->containers[$index]; if ($container->has($id)) { + try { + /** @psalm-suppress MixedReturnStatement */ + return $container->get($id); + } catch (NotFoundExceptionInterface) { + } + } + unset($this->lookupCache[$id]); + } + + foreach ($this->containers as $index => $container) { + if ($container->has($id)) { + $this->lookupCache[$id] = (int) $index; /** @psalm-suppress MixedReturnStatement */ return $container->get($id); } @@ -130,8 +152,17 @@ public function has($id): bool return false; } - foreach ($this->containers as $container) { + if (isset($this->lookupCache[$id], $this->containers[$this->lookupCache[$id]])) { + $index = $this->lookupCache[$id]; + if ($this->containers[$index]->has($id)) { + return true; + } + unset($this->lookupCache[$id]); + } + + foreach ($this->containers as $index => $container) { if ($container->has($id)) { + $this->lookupCache[$id] = (int) $index; return true; } } @@ -155,6 +186,7 @@ public function detach(ContainerInterface $container): void foreach ($this->containers as $i => $c) { if ($container === $c) { unset($this->containers[$i]); + $this->lookupCache = []; } } } diff --git a/src/Container.php b/src/Container.php index ef225056..ad818cc7 100644 --- a/src/Container.php +++ b/src/Container.php @@ -8,6 +8,7 @@ use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; +use RuntimeException; use Throwable; use Yiisoft\Definitions\ArrayDefinition; use Yiisoft\Definitions\DefinitionStorage; @@ -21,6 +22,7 @@ use function array_key_exists; use function array_keys; +use function count; use function implode; use function in_array; use function is_array; @@ -28,6 +30,7 @@ use function is_object; use function is_string; use function sprintf; +use function trim; /** * Container implements a [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) container. @@ -53,6 +56,7 @@ final class Container implements ContainerInterface * @var bool $validate If definitions should be validated. */ private readonly bool $validate; + private readonly int $hasCacheLimit; /** * @var array Cached instances. @@ -62,6 +66,11 @@ final class Container implements ContainerInterface private CompositeContainer $delegates; + /** + * @psalm-var array + */ + private array $hasCache = []; + /** * @var array Tagged service IDs. The structure is `['tagID' => ['service1', 'service2']]`. * @psalm-var array> @@ -84,16 +93,19 @@ public function __construct(?ContainerConfigInterface $config = null) { $config ??= ContainerConfig::create(); - $this->definitions = new DefinitionStorage( + $this->validate = $config->shouldValidate(); + $this->hasCacheLimit = $config->getHasCacheLimit(); + $this->setTags($config->getTags()); + + $definitions = $this->prepareDefinitions( + $config->getDefinitions(), [ ContainerInterface::class => $this, StateResetter::class => StateResetter::class, ], - $config->useStrictMode(), ); - $this->validate = $config->shouldValidate(); - $this->setTags($config->getTags()); - $this->addDefinitions($config->getDefinitions()); + $this->definitions = new DefinitionStorage($definitions, $config->useStrictMode()); + $this->addProviders($config->getProviders()); $this->setDelegates($config->getDelegates()); } @@ -109,20 +121,24 @@ public function __construct(?ContainerConfigInterface $config = null) */ public function has(string $id): bool { + if (array_key_exists($id, $this->hasCache)) { + return $this->hasCache[$id]; + } + try { if ($this->definitions->has($id)) { - return true; + return $this->cacheHasResult($id, true); } } catch (CircularReferenceException) { - return true; + return $this->cacheHasResult($id, true); } if (TagReference::isTagAlias($id)) { $tag = TagReference::extractTagFromAlias($id); - return isset($this->tags[$tag]); + return $this->cacheHasResult($id, isset($this->tags[$tag])); } - return false; + return $this->cacheHasResult($id, false); } /** @@ -204,6 +220,19 @@ public function get(string $id) return $this->instances[$id]; } + private function cacheHasResult(string $id, bool $result): bool + { + if ($this->hasCacheLimit === 0) { + return $result; + } + + if (count($this->hasCache) >= $this->hasCacheLimit) { + $this->hasCache = []; + } + + return $this->hasCache[$id] = $result; + } + private function prepareStateResetter(): StateResetter { $delegatesResetter = null; @@ -248,7 +277,12 @@ private function prepareStateResetter(): StateResetter */ private function addDefinition(string $id, mixed $definition): void { - [$definition, $meta] = DefinitionParser::parse($definition); + if (is_array($definition)) { + [$definition, $meta] = DefinitionParser::parse($definition); + } else { + $meta = []; + } + if ($this->validate) { $this->validateDefinition($definition, $id); // Only validate meta if it's not empty. @@ -269,6 +303,7 @@ private function addDefinition(string $id, mixed $definition): void } unset($this->instances[$id]); + $this->hasCache = []; $this->addDefinitionToStorage($id, $definition); } @@ -281,9 +316,109 @@ private function addDefinition(string $id, mixed $definition): void * @throws InvalidConfigException */ private function addDefinitions(array $config): void + { + $this->hasCache = []; + + foreach ($config as $id => $definition) { + if (!is_string($id)) { + throw new InvalidConfigException( + sprintf( + 'Key must be a string. %s given.', + get_debug_type($id), + ), + ); + } + /** @var string $id */ + + if (is_array($definition)) { + [$definition, $meta] = DefinitionParser::parse($definition); + } else { + $meta = []; + } + + if ($this->validate) { + $this->validateDefinition($definition, $id); + // Only validate meta if it's not empty. + if ($meta !== []) { + $this->validateMeta($meta); + } + } + /** + * @psalm-var array{reset?:Closure,tags?:string[]} $meta + */ + + // Process meta only if it has tags or reset callback. + if (isset($meta[self::META_TAGS])) { + $this->setDefinitionTags($id, $meta[self::META_TAGS]); + } + if (isset($meta[self::META_RESET])) { + $this->setDefinitionResetter($id, $meta[self::META_RESET]); + } + + unset($this->instances[$id]); + + $this->definitions->set($id, $definition); + + if ($id === StateResetter::class) { + $this->useResettersFromMeta = false; + } + } + } + + private function prepareDefinitions(array $config, array $definitions): array + { + if (!$this->validate) { + return $this->prepareDefinitionsWithoutValidation($config, $definitions); + } + + foreach ($config as $id => $definition) { + if (!is_string($id)) { + throw new InvalidConfigException( + sprintf( + 'Key must be a string. %s given.', + get_debug_type($id), + ), + ); + } + /** @var string $id */ + + if (is_array($definition)) { + [$definition, $meta] = DefinitionParser::parse($definition); + } else { + $meta = []; + } + + $this->validateDefinition($definition, $id); + // Only validate meta if it's not empty. + if ($meta !== []) { + $this->validateMeta($meta); + } + /** + * @psalm-var array{reset?:Closure,tags?:string[]} $meta + */ + + // Process meta only if it has tags or reset callback. + if (isset($meta[self::META_TAGS])) { + $this->setDefinitionTags($id, $meta[self::META_TAGS]); + } + if (isset($meta[self::META_RESET])) { + $this->setDefinitionResetter($id, $meta[self::META_RESET]); + } + + if ($id === StateResetter::class) { + $this->useResettersFromMeta = false; + } + + $definitions[$id] = $definition; + } + + return $definitions; + } + + private function prepareDefinitionsWithoutValidation(array $config, array $definitions): array { foreach ($config as $id => $definition) { - if ($this->validate && !is_string($id)) { + if (!is_string($id)) { throw new InvalidConfigException( sprintf( 'Key must be a string. %s given.', @@ -292,9 +427,30 @@ private function addDefinitions(array $config): void ); } /** @var string $id */ + if (is_array($definition)) { + [$definition, $meta] = DefinitionParser::parse($definition); + + /** + * @psalm-var array{reset?:Closure,tags?:string[]} $meta + */ + + // Process meta only if it has tags or reset callback. + if (isset($meta[self::META_TAGS])) { + $this->setDefinitionTags($id, $meta[self::META_TAGS]); + } + if (isset($meta[self::META_RESET])) { + $this->setDefinitionResetter($id, $meta[self::META_RESET]); + } + } - $this->addDefinition($id, $definition); + if ($id === StateResetter::class) { + $this->useResettersFromMeta = false; + } + + $definitions[$id] = $definition; } + + return $definitions; } /** @@ -309,6 +465,10 @@ private function setDelegates(array $delegates): void { $this->delegates = new CompositeContainer(); + if ($delegates === []) { + return; + } + $container = $this->get(ContainerInterface::class); foreach ($delegates as $delegate) { @@ -344,6 +504,13 @@ private function validateDefinition(mixed $definition, ?string $id = null): void return; } + if (is_string($definition)) { + if (trim($definition) === '') { + throw new InvalidConfigException('Invalid definition: class name must be a non-empty string.'); + } + return; + } + if (is_array($definition)) { if (isset($definition[DefinitionParser::IS_PREPARED_ARRAY_DEFINITION_DATA])) { $class = $definition['class']; @@ -543,14 +710,15 @@ private function build(string $id): mixed return $this->getTaggedServices($id); } - // Check if the definition exists. - if (!$this->definitions->has($id)) { + try { + $definition = $this->definitions->get($id); + } catch (RuntimeException) { throw new NotFoundException($id, $this->definitions->getBuildStack()); } $this->building[$id] = 1; try { - $normalizedDefinition = DefinitionNormalizer::normalize($this->definitions->get($id), $id); + $normalizedDefinition = DefinitionNormalizer::normalize($definition, $id); $object = $normalizedDefinition->resolve($this->get(ContainerInterface::class)); } finally { unset($this->building[$id]); diff --git a/src/ContainerConfig.php b/src/ContainerConfig.php index b324e9dc..882a2ec6 100644 --- a/src/ContainerConfig.php +++ b/src/ContainerConfig.php @@ -4,15 +4,20 @@ namespace Yiisoft\Di; +use InvalidArgumentException; + /** * Container configuration. */ final class ContainerConfig implements ContainerConfigInterface { + public const DEFAULT_HAS_CACHE_LIMIT = 1024; + private array $definitions = []; private array $providers = []; private array $tags = []; private bool $validate = true; + private int $hasCacheLimit = self::DEFAULT_HAS_CACHE_LIMIT; private array $delegates = []; private bool $useStrictMode = false; @@ -83,6 +88,25 @@ public function shouldValidate(): bool return $this->validate; } + /** + * @param int $limit Maximum number of cached `has()` results. `0` disables the cache. + */ + public function withHasCacheLimit(int $limit): self + { + if ($limit < 0) { + throw new InvalidArgumentException('Has cache limit must be greater than or equal to 0.'); + } + + $new = clone $this; + $new->hasCacheLimit = $limit; + return $new; + } + + public function getHasCacheLimit(): int + { + return $this->hasCacheLimit; + } + /** * @param array $delegates Container delegates. Each delegate is a callable in format * `function (ContainerInterface $container): ContainerInterface`. The container instance returned is used diff --git a/src/ContainerConfigInterface.php b/src/ContainerConfigInterface.php index 6be10e34..6f388fc9 100644 --- a/src/ContainerConfigInterface.php +++ b/src/ContainerConfigInterface.php @@ -29,6 +29,11 @@ public function getTags(): array; */ public function shouldValidate(): bool; + /** + * @return int Maximum number of cached `has()` results. `0` disables the cache. + */ + public function getHasCacheLimit(): int; + /** * @return array Container delegates. Each delegate is a callable in format * `function (ContainerInterface $container): ContainerInterface`. The container instance returned is used diff --git a/tests/Benchmark/ContainerBench.php b/tests/Benchmark/ContainerBench.php index 5e2a228d..01f361b7 100644 --- a/tests/Benchmark/ContainerBench.php +++ b/tests/Benchmark/ContainerBench.php @@ -147,6 +147,25 @@ public function benchConstruct(): void ); } + /** + * @Groups({"construct", "no-validation"}) + * + * @throws InvalidConfigException + * @throws NotInstantiableException + */ + public function benchConstructWithoutValidation(): void + { + $definitions = []; + for ($i = 0; $i < self::SERVICE_COUNT; $i++) { + $definitions["service$i"] = PropertyTestClass::class; + } + $container = new Container( + ContainerConfig::create() + ->withValidate(false) + ->withDefinitions($definitions), + ); + } + /** * @Groups({"lookup"}) * @ParamProviders({"provideDefinitions"}) @@ -171,6 +190,31 @@ public function benchSequentialLookups($params): void } } + /** + * @Groups({"lookup", "no-validation"}) + * @ParamProviders({"provideDefinitions"}) + */ + public function benchSequentialLookupsWithoutValidation($params): void + { + $definitions = []; + for ($i = 0; $i < self::SERVICE_COUNT; $i++) { + $definitions["service$i"] = $params['serviceClass']; + } + if (isset($params['otherDefinitions'])) { + $definitions = array_merge($definitions, $params['otherDefinitions']); + } + $container = new Container( + ContainerConfig::create() + ->withValidate(false) + ->withDefinitions($definitions), + ); + for ($i = 0; $i < self::SERVICE_COUNT / 2; $i++) { + // Do array lookup. + $index = $this->indexes[$i]; + $container->get("service$index"); + } + } + /** * @Groups({"lookup"}) * @ParamProviders({"provideDefinitions"}) @@ -195,6 +239,31 @@ public function benchRandomLookups($params): void } } + /** + * @Groups({"lookup", "no-validation"}) + * @ParamProviders({"provideDefinitions"}) + */ + public function benchRandomLookupsWithoutValidation($params): void + { + $definitions = []; + for ($i = 0; $i < self::SERVICE_COUNT; $i++) { + $definitions["service$i"] = $params['serviceClass']; + } + if (isset($params['otherDefinitions'])) { + $definitions = array_merge($definitions, $params['otherDefinitions']); + } + $container = new Container( + ContainerConfig::create() + ->withValidate(false) + ->withDefinitions($definitions), + ); + for ($i = 0; $i < self::SERVICE_COUNT / 2; $i++) { + // Do array lookup. + $index = $this->randomIndexes[$i]; + $container->get("service$index"); + } + } + /** * @Groups({"lookup"}) * @ParamProviders({"provideDefinitions"}) diff --git a/tests/Benchmark/TypicalUsageBench.php b/tests/Benchmark/TypicalUsageBench.php new file mode 100644 index 00000000..63d61f15 --- /dev/null +++ b/tests/Benchmark/TypicalUsageBench.php @@ -0,0 +1,286 @@ +cachedServiceContainer = new Container( + ContainerConfig::create() + ->withDefinitions([ + EngineInterface::class => EngineMarkOne::class, + Car::class => Car::class, + ]), + ); + $this->cachedServiceContainer->get(Car::class); + } + + /** + * Measures the hot path after the shared service was already built. + * + * @Groups({"lookup", "typical"}) + */ + public function benchCachedSharedService(): void + { + $this->cachedServiceContainer->get(Car::class); + } + + /** + * Measures first resolution of an autowired object graph by class name. + * + * @Groups({"lookup", "autowire", "typical"}) + * @Revs(100) + */ + public function benchAutowireObjectGraph(): void + { + $container = new Container( + ContainerConfig::create() + ->withDefinitions([ + EngineInterface::class => EngineMarkOne::class, + ]), + ); + + $container->get(Car::class); + } + + /** + * Measures first resolution of an autowired object graph by class name without eager definition validation. + * + * @Groups({"lookup", "autowire", "typical", "no-validation"}) + * @Revs(100) + */ + public function benchAutowireObjectGraphWithoutValidation(): void + { + $container = new Container( + ContainerConfig::create() + ->withValidate(false) + ->withDefinitions([ + EngineInterface::class => EngineMarkOne::class, + ]), + ); + + $container->get(Car::class); + } + + /** + * Measures explicit array definitions with constructor, setter, and reference resolution. + * + * @Groups({"lookup", "definition", "typical"}) + * @Revs(100) + */ + public function benchArrayDefinitionObjectGraph(): void + { + $container = new Container( + ContainerConfig::create() + ->withDefinitions([ + EngineInterface::class => EngineMarkOne::class, + ColorInterface::class => ColorRed::class, + 'car' => [ + 'class' => Car::class, + '__construct()' => [Reference::to(EngineInterface::class)], + 'setColor()' => [Reference::to(ColorInterface::class)], + ], + ]), + ); + + $container->get('car'); + } + + /** + * Measures explicit array definitions with constructor, setter, and reference resolution without eager validation. + * + * @Groups({"lookup", "definition", "typical", "no-validation"}) + * @Revs(100) + */ + public function benchArrayDefinitionObjectGraphWithoutValidation(): void + { + $container = new Container( + ContainerConfig::create() + ->withValidate(false) + ->withDefinitions([ + EngineInterface::class => EngineMarkOne::class, + ColorInterface::class => ColorRed::class, + 'car' => [ + 'class' => Car::class, + '__construct()' => [Reference::to(EngineInterface::class)], + 'setColor()' => [Reference::to(ColorInterface::class)], + ], + ]), + ); + + $container->get('car'); + } + + /** + * Measures first resolution of a callable factory definition. + * + * @Groups({"lookup", "factory", "typical"}) + * @Revs(100) + */ + public function benchFactoryDefinition(): void + { + $container = new Container( + ContainerConfig::create() + ->withDefinitions([ + ColorInterface::class => ColorRed::class, + 'car' => [CarFactory::class, 'createWithColor'], + ]), + ); + + $container->get('car'); + } + + /** + * Measures first resolution of a callable factory definition without eager validation. + * + * @Groups({"lookup", "factory", "typical", "no-validation"}) + * @Revs(100) + */ + public function benchFactoryDefinitionWithoutValidation(): void + { + $container = new Container( + ContainerConfig::create() + ->withValidate(false) + ->withDefinitions([ + ColorInterface::class => ColorRed::class, + 'car' => [CarFactory::class, 'createWithColor'], + ]), + ); + + $container->get('car'); + } + + /** + * Measures collecting all services registered under a tag. + * + * @Groups({"lookup", "tag", "typical"}) + * @Revs(100) + */ + public function benchTaggedServices(): void + { + $container = new Container( + ContainerConfig::create() + ->withDefinitions([ + EngineMarkOne::class => [ + 'class' => EngineMarkOne::class, + 'tags' => ['engine'], + ], + EngineMarkTwo::class => [ + 'class' => EngineMarkTwo::class, + 'tags' => ['engine'], + ], + 'engine-reference' => [ + 'definition' => Reference::to(EngineMarkOne::class), + 'tags' => ['engine'], + ], + ]), + ); + + $container->get(TagReference::id('engine')); + } + + /** + * Measures collecting all services registered under a tag without eager validation. + * + * @Groups({"lookup", "tag", "typical", "no-validation"}) + * @Revs(100) + */ + public function benchTaggedServicesWithoutValidation(): void + { + $container = new Container( + ContainerConfig::create() + ->withValidate(false) + ->withDefinitions([ + EngineMarkOne::class => [ + 'class' => EngineMarkOne::class, + 'tags' => ['engine'], + ], + EngineMarkTwo::class => [ + 'class' => EngineMarkTwo::class, + 'tags' => ['engine'], + ], + 'engine-reference' => [ + 'definition' => Reference::to(EngineMarkOne::class), + 'tags' => ['engine'], + ], + ]), + ); + + $container->get(TagReference::id('engine')); + } + + /** + * Measures fallback through delegates configured on the container. + * + * @Groups({"lookup", "delegate", "typical"}) + * @Revs(100) + */ + public function benchDelegateFallback(): void + { + $container = new Container( + ContainerConfig::create() + ->withDelegates([ + static fn(ContainerInterface $container): ContainerInterface => new Container( + ContainerConfig::create() + ->withDefinitions([ + EngineInterface::class => EngineMarkOne::class, + ]), + ), + ]), + ); + + $container->get(EngineInterface::class); + } + + /** + * Measures fallback through delegates configured on the container without eager validation. + * + * @Groups({"lookup", "delegate", "typical", "no-validation"}) + * @Revs(100) + */ + public function benchDelegateFallbackWithoutValidation(): void + { + $container = new Container( + ContainerConfig::create() + ->withValidate(false) + ->withDelegates([ + static fn(ContainerInterface $container): ContainerInterface => new Container( + ContainerConfig::create() + ->withValidate(false) + ->withDefinitions([ + EngineInterface::class => EngineMarkOne::class, + ]), + ), + ]), + ); + + $container->get(EngineInterface::class); + } +} diff --git a/tests/Unit/CompositeContainerTest.php b/tests/Unit/CompositeContainerTest.php index 96628127..120c5676 100644 --- a/tests/Unit/CompositeContainerTest.php +++ b/tests/Unit/CompositeContainerTest.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; use Yiisoft\Di\CompositeContainer; use Yiisoft\Di\CompositeNotFoundException; use Yiisoft\Di\Container; @@ -14,8 +15,10 @@ use Yiisoft\Di\Tests\Support\EngineMarkOne; use Yiisoft\Di\Tests\Support\EngineMarkTwo; use Yiisoft\Di\Tests\Support\NonPsrContainer; +use Yiisoft\Test\Support\Container\Exception\NotFoundException; use Yiisoft\Test\Support\Container\SimpleContainer; +use function array_key_exists; use function PHPUnit\Framework\assertFalse; use function PHPUnit\Framework\assertSame; @@ -110,4 +113,55 @@ public function testHasTagWithoutYiiContainer(): void assertFalse($container->has('tag@engine')); } + + public function testHasRechecksCachedContainer(): void + { + $container = new CompositeContainer(); + $delegate = new MutableContainer([ + 'engine' => new EngineMarkOne(), + ]); + + $container->attach($delegate); + + $this->assertTrue($container->has('engine')); + unset($delegate->definitions['engine']); + + assertFalse($container->has('engine')); + } + + public function testGetFallsBackWhenCachedContainerNoLongerHasDefinition(): void + { + $container = new CompositeContainer(); + $firstDelegate = new MutableContainer(['engine' => new EngineMarkOne()]); + $secondDelegate = new MutableContainer(['engine' => new EngineMarkTwo()]); + + $container->attach($firstDelegate); + $container->attach($secondDelegate); + + $this->assertInstanceOf(EngineMarkOne::class, $container->get('engine')); + unset($firstDelegate->definitions['engine']); + + $this->assertInstanceOf(EngineMarkTwo::class, $container->get('engine')); + } +} + +final class MutableContainer implements ContainerInterface +{ + public function __construct( + public array $definitions, + ) {} + + public function get($id) + { + if (!$this->has($id)) { + throw new NotFoundException($id); + } + + return $this->definitions[$id]; + } + + public function has($id): bool + { + return array_key_exists($id, $this->definitions); + } } diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php index d11b7684..2cd4c894 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -54,9 +54,11 @@ use Yiisoft\Definitions\Reference; use Yiisoft\Injector\Injector; use Yiisoft\Test\Support\Container\SimpleContainer; +use InvalidArgumentException; use function PHPUnit\Framework\assertInstanceOf; use function PHPUnit\Framework\assertSame; +use function count; /** * ContainerTest contains tests for \Yiisoft\Di\Container @@ -83,6 +85,19 @@ public function testSettingScalars(): void $container->get('scalar'); } + public function testEmptyStringDefinition(): void + { + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Invalid definition: class name must be a non-empty string.'); + + new Container( + ContainerConfig::create() + ->withDefinitions([ + 'empty' => '', + ]), + ); + } + public function testIntegerKeys(): void { $this->expectException(InvalidConfigException::class); @@ -97,6 +112,113 @@ public function testIntegerKeys(): void $container->get(Car::class); } + public function testIntegerKeysWithoutValidation(): void + { + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Key must be a string. int given.'); + + new Container( + ContainerConfig::create() + ->withValidate(false) + ->withDefinitions([ + [ + 'class' => EngineMarkOne::class, + 'tags' => ['engine'], + ], + ]), + ); + } + + public function testIntegerKeysInProviderWithoutValidation(): void + { + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Key must be a string. int given.'); + + $provider = new class implements ServiceProviderInterface { + public function getDefinitions(): array + { + return [ + [ + 'class' => EngineMarkOne::class, + 'tags' => ['engine'], + ], + ]; + } + + public function getExtensions(): array + { + return []; + } + }; + + new Container( + ContainerConfig::create() + ->withValidate(false) + ->withProviders([$provider]), + ); + } + + public function testMetadataWithoutValidation(): void + { + $container = new Container( + ContainerConfig::create() + ->withValidate(false) + ->withDefinitions([ + EngineMarkOne::class => [ + 'class' => EngineMarkOne::class, + 'tags' => ['engine'], + 'reset' => function (): void { + $this->number = 7; + }, + ], + StateResetter::class => StateResetter::class, + ]), + ); + + $engines = $container->get('tag@engine'); + + $this->assertIsArray($engines); + $this->assertSame(EngineMarkOne::class, $engines[0]::class); + } + + public function testMetadataInProviderDefinitions(): void + { + $provider = new class implements ServiceProviderInterface { + public function getDefinitions(): array + { + return [ + EngineMarkOne::class => [ + 'class' => EngineMarkOne::class, + 'tags' => ['engine'], + 'reset' => function (): void { + $this->number = 7; + }, + ], + ]; + } + + public function getExtensions(): array + { + return []; + } + }; + + $container = new Container( + ContainerConfig::create() + ->withProviders([$provider]), + ); + + $engine = $container->get(EngineMarkOne::class); + $engine->setNumber(42); + $container->get(StateResetter::class)->reset(); + + $engines = $container->get('tag@engine'); + + $this->assertSame(7, $engine->getNumber()); + $this->assertIsArray($engines); + $this->assertSame($engine, $engines[0]); + } + public function testNullableClassDependency(): void { $container = new Container(); @@ -167,6 +289,69 @@ public function testHas(bool $expected, $id): void $this->assertSame($expected, $container->has($id)); } + public function testHasCacheIsBounded(): void + { + $definitions = []; + for ($i = 0; $i < 1_100; $i++) { + $definitions['existing-service-' . $i] = EngineMarkOne::class; + } + + $container = new Container( + ContainerConfig::create() + ->withDefinitions($definitions), + ); + + $this->assertSame(1024, ContainerConfig::create()->getHasCacheLimit()); + + for ($i = 0; $i < 1_100; $i++) { + $container->has('missing-service-' . $i); + $container->has('existing-service-' . $i); + } + + $cacheSize = (fn(): int => count($this->hasCache))->call($container); + + $this->assertLessThanOrEqual(1024, $cacheSize); + } + + public function testHasCacheLimitIsConfigurable(): void + { + $container = new Container( + ContainerConfig::create() + ->withHasCacheLimit(2), + ); + + $container->has('missing-service-1'); + $container->has('missing-service-2'); + $container->has('missing-service-3'); + + $cacheSize = (fn(): int => count($this->hasCache))->call($container); + + $this->assertSame(1, $cacheSize); + } + + public function testHasCacheCanBeDisabled(): void + { + $config = ContainerConfig::create() + ->withHasCacheLimit(0); + $container = new Container($config); + + $this->assertSame(0, $config->getHasCacheLimit()); + $this->assertFalse($container->has('missing-service')); + + $cacheSize = (fn(): int => count($this->hasCache))->call($container); + + $this->assertSame(0, $cacheSize); + } + + public function testHasCacheLimitCanNotBeNegative(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Has cache limit must be greater than or equal to 0.'); + + ContainerConfig::create() + ->withHasCacheLimit(-1); + } + public static function dataUnionTypes(): array { return [ @@ -672,6 +857,33 @@ public function testFalsePositiveCircularReferenceWithStringID(): void } } + public function testAddDefinitionWithMetadata(): void + { + $container = new Container(); + + (fn(string $id, $definition) => $this->addDefinition($id, $definition))->call( + $container, + EngineMarkOne::class, + [ + 'class' => EngineMarkOne::class, + 'tags' => ['engine'], + 'reset' => function (): void { + $this->number = 7; + }, + ], + ); + + $engine = $container->get(EngineMarkOne::class); + $engine->setNumber(42); + $container->get(StateResetter::class)->reset(); + + $engines = $container->get('tag@engine'); + + $this->assertSame(7, $engine->getNumber()); + $this->assertIsArray($engines); + $this->assertSame($engine, $engines[0]); + } + public function testCallable(): void { $config = ContainerConfig::create() diff --git a/tests/Unit/Helpers/DefinitionParserTest.php b/tests/Unit/Helpers/DefinitionParserTest.php index e5f9485a..5beaf406 100644 --- a/tests/Unit/Helpers/DefinitionParserTest.php +++ b/tests/Unit/Helpers/DefinitionParserTest.php @@ -11,6 +11,14 @@ final class DefinitionParserTest extends TestCase { + public function testParseNonArrayDefinition(): void + { + [$definition, $meta] = DefinitionParser::parse(EngineMarkOne::class); + + $this->assertSame(EngineMarkOne::class, $definition); + $this->assertSame([], $meta); + } + public function testParseCallableDefinition(): void { $fn = static fn() => new EngineMarkOne();