From ae2b5d765bd0d5573f0633c2e69aeeb5c386e1b5 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:28:11 +0000 Subject: [PATCH] feat: param interface --- src/DI/Container.php | 91 +++++---- src/DI/Dependency.php | 32 ---- tests/ContainerTest.php | 374 ++++++++++++++++++++++++------------- tools/rector/composer.lock | 2 +- 4 files changed, 282 insertions(+), 217 deletions(-) delete mode 100644 src/DI/Dependency.php diff --git a/src/DI/Container.php b/src/DI/Container.php index 3790c40..46b93ac 100644 --- a/src/DI/Container.php +++ b/src/DI/Container.php @@ -2,10 +2,7 @@ namespace Utopia\DI; -use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; -use Utopia\DI\Exceptions\ContainerException; -use Utopia\DI\Exceptions\NotFoundException; /** * @phpstan-consistent-constructor @@ -13,20 +10,29 @@ class Container implements ContainerInterface { /** - * @var array + * Map of dependency IDs to their required dependency IDs. + * + * @var array> */ - private array $definitions = []; + private array $dependencies = []; /** - * @var array + * Map of dependency IDs to their factory callables. + * + * @var array */ - private array $resolved = []; + private array $factories = []; /** - * @var array + * Map of dependency IDs to a cache of resolved instances. + * + * @var array */ - private array $resolving = []; + private array $concrete = []; + /** + * @param ContainerInterface|null $parent Optional parent container for hierarchical resolution. + */ public function __construct( private readonly ?ContainerInterface $parent = null, ) { @@ -35,63 +41,42 @@ public function __construct( /** * Register a dependency factory on the current container. * - * @param callable(ContainerInterface): mixed $factory + * If a dependency with the same ID already exists, it will be overridden. + * + * @param string $id Unique identifier for the dependency. + * @param callable $factory Factory callable invoked to create the instance. + * @param list $dependencies List of dependency IDs required by the factory. */ - public function set(string $key, callable $factory): static + public function set(string $id, callable $factory, array $dependencies): static { - $this->definitions[$key] = $factory; - unset($this->resolved[$key]); + $this->factories[$id] = $factory; + $this->dependencies[$id] = $dependencies; return $this; } - /** - * Resolve an entry from the current container or its parent chain. - * - * - * @throws ContainerExceptionInterface - */ public function get(string $id): mixed { - if (\array_key_exists($id, $this->resolved)) { - return $this->resolved[$id]; + if (\array_key_exists($id, $this->concrete)) { + return $this->concrete[$id]; } - if (\array_key_exists($id, $this->definitions)) { - if (isset($this->resolving[$id])) { - throw new ContainerException('Circular dependency detected for "'.$id.'".'); - } - - $this->resolving[$id] = true; - - try { - $resolved = ($this->definitions[$id])($this); - } catch (NotFoundException|ContainerExceptionInterface $exception) { - throw $exception; - } catch (\Throwable $exception) { - throw new ContainerException( - 'Failed to resolve dependency "'.$id.'".', - previous: $exception - ); - } finally { - unset($this->resolving[$id]); - } - - $this->resolved[$id] = $resolved; - - return $resolved; + if (\array_key_exists($id, $this->factories)) { + $concrete = $this->build($id); + $this->concrete[$id] = $concrete; + return $concrete; } if ($this->parent instanceof ContainerInterface) { return $this->parent->get($id); } - throw new NotFoundException('Dependency not found: '.$id); + throw new Exceptions\NotFoundException("Dependency $id not found"); } public function has(string $id): bool { - if (\array_key_exists($id, $this->definitions)) { + if (\array_key_exists($id, $this->factories)) { return true; } @@ -99,10 +84,18 @@ public function has(string $id): bool } /** - * Create a child container that falls back to the current container. + * Build a dependency by resolving its dependencies and invoking its factory. + * + * @param string $id Identifier of the dependency to build. + * @return mixed The constructed dependency instance. */ - public function scope(): static + private function build(string $id): mixed { - return new static($this); + $dependencies = []; + foreach ($this->dependencies[$id] as $dependency) { + $dependencies[] = $this->get($dependency); + } + + return \call_user_func($this->factories[$id], ...$dependencies); } } diff --git a/src/DI/Dependency.php b/src/DI/Dependency.php deleted file mode 100644 index 1846fea..0000000 --- a/src/DI/Dependency.php +++ /dev/null @@ -1,32 +0,0 @@ -injections as $injection) { - $arguments[$injection] = $container->get($injection); - } - - return ($this->callback)(...$arguments); - } -} diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 7ad2fcb..e066dd2 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -5,212 +5,316 @@ namespace Utopia\DI\Tests; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; -use RuntimeException; +use Psr\Container\NotFoundExceptionInterface; use Utopia\DI\Container; -use Utopia\DI\Dependency; -use Utopia\DI\Exceptions\ContainerException; -use Utopia\DI\Exceptions\NotFoundException; final class ContainerTest extends TestCase { - protected ?Container $container = null; + public function testSetAndGetDependency(): void + { + $container = new Container(); + $container->set('foo', fn (): string => 'bar', []); + + $this->assertSame('bar', $container->get('foo')); + } + + public function testSetReturnsContainer(): void + { + $container = new Container(); + $result = $container->set('foo', fn (): string => 'bar', []); + + $this->assertSame($container, $result); + } + + public function testGetReturnsCachedInstance(): void + { + $callCount = 0; + $container = new Container(); + $container->set('counter', function () use (&$callCount): \stdClass { + $callCount++; + return new \stdClass(); + }, []); + + $first = $container->get('counter'); + $second = $container->get('counter'); + + $this->assertSame($first, $second); + $this->assertSame(1, $callCount); + } + + public function testGetThrowsForUnknownDependency(): void + { + $container = new Container(); + + $this->expectException(NotFoundExceptionInterface::class); + $this->expectExceptionMessage('Dependency missing not found'); + + $container->get('missing'); + } + + public function testHasReturnsTrueForRegisteredDependency(): void + { + $container = new Container(); + $container->set('foo', fn (): string => 'bar', []); + + $this->assertTrue($container->has('foo')); + } + + public function testHasReturnsFalseForUnregisteredDependency(): void + { + $container = new Container(); + + $this->assertFalse($container->has('missing')); + } + + public function testHasDelegatesToParent(): void + { + $parent = new Container(); + $parent->set('parentDep', fn (): string => 'fromParent', []); + + $child = new Container($parent); + + $this->assertTrue($child->has('parentDep')); + } + + public function testHasReturnsFalseWhenNeitherChildNorParentHasDependency(): void + { + $parent = new Container(); + $child = new Container($parent); + + $this->assertFalse($child->has('missing')); + } - public function setUp(): void + public function testBuildWithSingleDependency(): void { - $this->container = new Container(); + $container = new Container(); + $container->set('config', fn (): string => 'configValue', []); + $container->set('service', fn ($dep): string => "service:$dep", ['config']); - $this->container - ->set('age', fn (ContainerInterface $container): int => 25) - ->set( - 'user', - fn (ContainerInterface $container): string => 'John Doe is '.$container->get('age').' years old.' - ) - ; + $this->assertSame('service:configValue', $container->get('service')); } - public function tearDown(): void + public function testDependencyChaining(): void { - $this->container = null; + $container = new Container(); + $container->set('a', fn (): string => 'A', []); + $container->set('b', fn ($dep): string => "B($dep)", ['a']); + $container->set('c', fn ($dep): string => "C($dep)", ['b']); + + $this->assertSame('C(B(A))', $container->get('c')); } - public function testImplementsPsrContainerInterface(): void + public function testFactoryReceivesResolvedDependency(): void { - $this->assertInstanceOf(ContainerInterface::class, $this->container); + $container = new Container(); + $container->set('greeting', fn (): string => 'Hello', []); + $container->set('message', fn ($greeting): string => "$greeting, World!", ['greeting']); + + $this->assertSame('Hello, World!', $container->get('message')); } - public function testResolution(): void + public function testSetOverridesPreviousFactory(): void { - $this->assertSame('John Doe is 25 years old.', $this->container->get('user')); + $container = new Container(); + $container->set('foo', fn (): string => 'first', []); + $container->set('foo', fn (): string => 'second', []); + + $this->assertSame('second', $container->get('foo')); } - public function testCanRegisterDependencyObjects(): void + public function testFactoryCanReturnNull(): void { $container = new Container(); + $container->set('nullable', fn (): null => null, []); + + $this->assertNull($container->get('nullable')); + } - $container - ->set( - key: 'age', - factory: new Dependency( - injections: [], - callback: fn (): int => 25 - ) - ) - ->set( - key: 'john', - factory: new Dependency( - injections: ['age'], - callback: fn (int $age): string => 'John Doe is '.$age.' years old.' - ) - ) - ; + public function testFactoryCanReturnDifferentTypes(): void + { + $container = new Container(); + $container->set('int', fn (): int => 42, []); + $container->set('array', fn (): array => [1, 2, 3], []); + $container->set('object', fn (): \stdClass => new \stdClass(), []); - $this->assertSame('John Doe is 25 years old.', $container->get('john')); + $this->assertSame(42, $container->get('int')); + $this->assertSame([1, 2, 3], $container->get('array')); + $this->assertInstanceOf(\stdClass::class, $container->get('object')); } - public function testDependencyOrderDoesNotNeedToMatchCallbackParameterOrder(): void + public function testNullCachedValueIsReturnedWithoutRebuild(): void { + $callCount = 0; $container = new Container(); + $container->set('nullable', function () use (&$callCount): null { + $callCount++; + return null; + }, []); - $container - ->set( - key: 'a', - factory: new Dependency( - injections: [], - callback: fn (): string => 'value-a' - ) - ) - ->set( - key: 'b', - factory: new Dependency( - injections: [], - callback: fn (): string => 'value-b' - ) - ) - ->set( - key: 'combined', - factory: new Dependency( - injections: ['a', 'b'], - callback: fn (string $b, string $a): string => "{$a}:{$b}" - ) - ) - ; + $container->get('nullable'); + $container->get('nullable'); - $this->assertSame('value-a:value-b', $container->get('combined')); + $this->assertSame(1, $callCount); } - public function testFactoriesAreResolvedOncePerContainer(): void + public function testChildContainerDoesNotAffectParent(): void { - $counter = 0; + $parent = new Container(); + $child = new Container($parent); + $child->set('childOnly', fn (): string => 'childValue', []); + + $this->assertTrue($child->has('childOnly')); + $this->assertFalse($parent->has('childOnly')); + } - $this->container->set('counter', function (ContainerInterface $container) use (&$counter): int { - $counter++; + public function testGetResolvesFromParent(): void + { + $parent = new Container(); + $parent->set('foo', fn (): string => 'fromParent', []); - return $counter; - }); + $child = new Container($parent); - $this->assertSame(1, $this->container->get('counter')); - $this->assertSame(1, $this->container->get('counter')); + $this->assertSame('fromParent', $child->get('foo')); } - public function testScopedContainersFallbackToParentDefinitions(): void + public function testGetResolvesFromGrandparent(): void { - $request = $this->container->scope(); + $grandparent = new Container(); + $grandparent->set('foo', fn (): string => 'fromGrandparent', []); + + $parent = new Container($grandparent); + $child = new Container($parent); - $this->assertSame('John Doe is 25 years old.', $request->get('user')); - $this->assertTrue($request->has('user')); + $this->assertSame('fromGrandparent', $child->get('foo')); } - public function testScopedContainersCanOverrideParentDefinitions(): void + public function testChildOverridesParentDependency(): void { - $request = $this->container->scope(); + $parent = new Container(); + $parent->set('foo', fn (): string => 'fromParent', []); - $request - ->set('age', fn (ContainerInterface $container): int => 30) - ->set( - 'user', - fn (ContainerInterface $container): string => 'John Doe is '.$container->get('age').' years old.' - ) - ; + $child = new Container($parent); + $child->set('foo', fn (): string => 'fromChild', []); - $this->assertSame('John Doe is 30 years old.', $request->get('user')); - $this->assertSame('John Doe is 25 years old.', $this->container->get('user')); + $this->assertSame('fromChild', $child->get('foo')); + $this->assertSame('fromParent', $parent->get('foo')); } - public function testScopeUsesParentCacheUntilDefinitionsAreOverridden(): void + public function testGetThrowsWhenNotInChildOrParent(): void { - $counter = 0; + $parent = new Container(); + $child = new Container($parent); - $this->container->set('counter', function (ContainerInterface $container) use (&$counter): int { - $counter++; + $this->expectException(NotFoundExceptionInterface::class); + $child->get('missing'); + } - return $counter; - }); + public function testParentDependencyIsCachedInParent(): void + { + $callCount = 0; + $parent = new Container(); + $parent->set('singleton', function () use (&$callCount): \stdClass { + $callCount++; + return new \stdClass(); + }, []); - $request = $this->container->scope(); + $child = new Container($parent); - $this->assertSame(1, $this->container->get('counter')); - $this->assertSame(1, $request->get('counter')); + $fromChild = $child->get('singleton'); + $fromParent = $parent->get('singleton'); - $request->set('counter', function (ContainerInterface $container) use (&$counter): int { - $counter++; + $this->assertSame($fromChild, $fromParent); + $this->assertSame(1, $callCount); + } - return $counter; - }); + public function testChildFactoryCanDependOnParentDependency(): void + { + $parent = new Container(); + $parent->set('config', fn (): string => 'prodConfig', []); + + $child = new Container($parent); + $child->set('service', fn ($cfg): string => "service:$cfg", ['config']); - $this->assertSame(2, $request->get('counter')); - $this->assertSame(2, $request->get('counter')); + $this->assertSame('service:prodConfig', $child->get('service')); } - public function testCanCacheNullValues(): void + public function testParentFactoryResolvesChildDependencyViaChildGet(): void { - $counter = 0; + $parent = new Container(); + $parent->set('greeter', fn ($name): string => "Hello, $name", ['name']); - $this->container->set('nullable', function (ContainerInterface $container) use (&$counter): null { - $counter++; + $child = new Container($parent); + $child->set('name', fn (): string => 'Alice', []); - return null; - }); + // Parent factory is resolved through parent->get(), which looks in parent's scope + // The child provides 'name' but parent resolves its own dependencies from its own container + // This should throw because 'name' is not in parent + $this->expectException(NotFoundExceptionInterface::class); + $child->get('greeter'); + } + + public function testMultipleSiblingContainersShareParent(): void + { + $parent = new Container(); + $parent->set('shared', fn (): \stdClass => new \stdClass(), []); + + $child1 = new Container($parent); + $child2 = new Container($parent); - $this->assertNull($this->container->get('nullable')); - $this->assertNull($this->container->get('nullable')); - $this->assertSame(1, $counter); + $this->assertSame($child1->get('shared'), $child2->get('shared')); } - public function testMissingDependencyThrowsNotFoundException(): void + public function testHasDelegatesToGrandparent(): void { - $this->expectException(NotFoundException::class); - $this->expectExceptionMessage('Dependency not found: missing'); + $grandparent = new Container(); + $grandparent->set('deep', fn (): string => 'deepValue', []); + + $parent = new Container($grandparent); + $child = new Container($parent); - $this->container->get('missing'); + $this->assertTrue($child->has('deep')); } - public function testFactoryFailuresThrowContainerException(): void + public function testBuildWithMultipleDependencies(): void { - $this->container->set('broken', function (ContainerInterface $container): never { - throw new RuntimeException('boom'); - }); + $container = new Container(); + $container->set('first', fn (): string => 'A', []); + $container->set('second', fn (): string => 'B', []); + $container->set('combined', fn (string $a, string $b): string => "$a+$b", ['first', 'second']); - try { - $this->container->get('broken'); - $this->fail('Expected a container exception.'); - } catch (ContainerException $exception) { - $this->assertSame('Failed to resolve dependency "broken".', $exception->getMessage()); - $this->assertInstanceOf(RuntimeException::class, $exception->getPrevious()); - $this->assertSame('boom', $exception->getPrevious()->getMessage()); - } + $this->assertSame('A+B', $container->get('combined')); } - public function testCircularDependenciesThrowContainerException(): void + public function testBuildWithThreeDependencies(): void { - $this->container - ->set('a', fn (ContainerInterface $container) => $container->get('b')) - ->set('b', fn (ContainerInterface $container) => $container->get('a')) - ; + $container = new Container(); + $container->set('x', fn (): int => 1, []); + $container->set('y', fn (): int => 2, []); + $container->set('z', fn (): int => 3, []); + $container->set('sum', fn (int $x, int $y, int $z): int => $x + $y + $z, ['x', 'y', 'z']); - $this->expectException(ContainerException::class); - $this->expectExceptionMessage('Circular dependency detected for "a".'); + $this->assertSame(6, $container->get('sum')); + } + + public function testMultipleDependenciesPreservesOrder(): void + { + $container = new Container(); + $container->set('a', fn (): string => 'first', []); + $container->set('b', fn (): string => 'second', []); + $container->set('c', fn (): string => 'third', []); + $container->set('ordered', fn (string $a, string $b, string $c): string => "$a,$b,$c", ['a', 'b', 'c']); + + $this->assertSame('first,second,third', $container->get('ordered')); + } + + public function testMultipleDependenciesAllReceived(): void + { + $container = new Container(); + $container->set('dep1', fn (): string => 'val1', []); + $container->set('dep2', fn (): string => 'val2', []); + $container->set('collector', fn (string $a, string $b): array => [$a, $b], ['dep1', 'dep2']); - $this->container->get('a'); + $result = $container->get('collector'); + $this->assertSame(['val1', 'val2'], $result); } } diff --git a/tools/rector/composer.lock b/tools/rector/composer.lock index 2eb50f1..cb6336e 100644 --- a/tools/rector/composer.lock +++ b/tools/rector/composer.lock @@ -131,5 +131,5 @@ "platform-overrides": { "php": "8.3.0" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" }