From 5875de0ab261baa3c1a7332b703eb72ac1d1e015 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 3 Sep 2025 19:33:08 +0400 Subject: [PATCH 1/5] Refactor Di Container to Support Abstract-to-Concrete Bindings --- src/Di/Di.php | 114 +++++++++++++++++++----------- src/Di/Exceptions/DiException.php | 36 +++++++++- tests/Unit/Di/DiTest.php | 96 ++++++++++++++++++++++++- 3 files changed, 202 insertions(+), 44 deletions(-) diff --git a/src/Di/Di.php b/src/Di/Di.php index d011ce35c..ca908d785 100644 --- a/src/Di/Di.php +++ b/src/Di/Di.php @@ -9,7 +9,7 @@ * @author Arman Ag. * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) * @link http://quantum.softberg.org/ - * @since 2.9.7 + * @since 2.9.8 */ namespace Quantum\Di; @@ -39,47 +39,56 @@ class Di */ private static $container = []; + /** + * @var array + */ + private static $resolving = []; + /** * Loads dependency definitions */ public static function registerDependencies() { - foreach (self::coreDependencies() as $dependency) { - if (!self::isRegistered($dependency)) { - self::register($dependency); - } - } - - foreach (self::userDependencies() as $dependency) { - if (!self::isRegistered($dependency)) { - self::register($dependency); + foreach (self::coreDependencies() + self::userDependencies() as $abstract => $concrete) { + if (!self::isRegistered($abstract)) { + self::register($concrete, $abstract); } } } /** * Registers new dependency - * @param string $dependency - * @return bool + * @param string $concrete + * @param string|null $abstract + * @throws DiException */ - public static function register(string $dependency): bool + public static function register(string $concrete, ?string $abstract = null) { - if (!in_array($dependency, self::$dependencies) && class_exists($dependency)) { - self::$dependencies[] = $dependency; - return true; + $key = $abstract ?? $concrete; + + if (isset(self::$dependencies[$key])) { + throw DiException::dependencyAlreadyRegistered($key); + } + + if (!class_exists($concrete)) { + throw DiException::dependencyNotInstantiable($concrete); + } + + if ($abstract !== null && !class_exists($abstract) && !interface_exists($abstract)) { + throw DiException::invalidAbstractDependency($abstract); } - return false; + self::$dependencies[$key] = $concrete; } /** * Checks if a dependency registered - * @param string $dependency + * @param string $abstract * @return bool */ - public static function isRegistered(string $dependency): bool + public static function isRegistered(string $abstract): bool { - return in_array($dependency, self::$dependencies); + return isset(self::$dependencies[$abstract]); } /** @@ -140,47 +149,57 @@ public static function reset(): void { self::$dependencies = []; self::$container = []; + self::$resolving = []; } /** * Resolves the dependency - * @param string $dependency + * @param string $abstract * @param array $args * @param bool $singleton * @return mixed * @throws DiException * @throws ReflectionException */ - private static function resolve(string $dependency, array $args = [], bool $singleton = true) + private static function resolve(string $abstract, array $args = [], bool $singleton = true) { - if ($singleton) { - if (!isset(self::$container[$dependency])) { - self::$container[$dependency] = self::instantiate($dependency, $args); + self::checkCircularDependency($abstract); + + self::$resolving[$abstract] = true; + + try { + $concrete = self::$dependencies[$abstract]; + + if ($singleton) { + if (!isset(self::$container[$abstract])) { + self::$container[$abstract] = self::instantiate($concrete, $args); + } + return self::$container[$abstract]; } - return self::$container[$dependency]; + return self::instantiate($concrete, $args); + } finally { + unset(self::$resolving[$abstract]); } - - return self::instantiate($dependency, $args); } /** * Instantiates the dependency - * @param string $dependency + * @param string $concrete * @param array $args * @return mixed * @throws DiException * @throws ReflectionException */ - private static function instantiate(string $dependency, array $args = []) + private static function instantiate(string $concrete, array $args = []) { - $class = new ReflectionClass($dependency); + $class = new ReflectionClass($concrete); $constructor = $class->getConstructor(); $params = $constructor ? self::resolveParameters($constructor->getParameters(), $args) : []; - return new $dependency(...$params); + return new $concrete(...$params); } /** @@ -214,8 +233,10 @@ private static function resolveParameter(ReflectionParameter $param, ?array &$ar { $type = $param->getType() ? $param->getType()->getName() : null; - if ($type && self::instantiable($type)) { - return self::create($type); + $concrete = self::$dependencies[$type] ?? $type; + + if (self::instantiable($concrete)) { + return self::create($concrete); } if ($type === 'array') { @@ -231,12 +252,25 @@ private static function resolveParameter(ReflectionParameter $param, ?array &$ar /** * Checks if the class is instantiable - * @param string $type + * @param string $class * @return bool */ - protected static function instantiable(string $type): bool + protected static function instantiable(string $class): bool { - return class_exists($type) && (new ReflectionClass($type))->isInstantiable(); + return class_exists($class) && (new ReflectionClass($class))->isInstantiable(); + } + + /** + * @param string $abstract + * @return void + * @throws DiException + */ + private static function checkCircularDependency(string $abstract): void + { + if (isset(self::$resolving[$abstract])) { + $chain = implode(' -> ', array_keys(self::$resolving)) . ' -> ' . $abstract; + throw DiException::circularDependency($chain); + } } /** @@ -261,9 +295,9 @@ private static function userDependencies(): array private static function coreDependencies(): array { return [ - \Quantum\Loader\Loader::class, - \Quantum\Http\Request::class, - \Quantum\Http\Response::class, + \Quantum\Loader\Loader::class => \Quantum\Loader\Loader::class, + \Quantum\Http\Request::class => \Quantum\Http\Request::class, + \Quantum\Http\Response::class => \Quantum\Http\Response::class, ]; } } diff --git a/src/Di/Exceptions/DiException.php b/src/Di/Exceptions/DiException.php index 9d2ec70f5..8f0945463 100644 --- a/src/Di/Exceptions/DiException.php +++ b/src/Di/Exceptions/DiException.php @@ -9,7 +9,7 @@ * @author Arman Ag. * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) * @link http://quantum.softberg.org/ - * @since 2.9.7 + * @since 2.9.8 */ namespace Quantum\Di\Exceptions; @@ -26,8 +26,40 @@ class DiException extends BaseException * @param string $name * @return DiException */ - public static function dependencyNotRegistered(string $name): DiException + public static function dependencyNotRegistered($name): DiException { return new self(t('exception.dependency_not_registered', $name), E_ERROR); } + + public static function dependencyAlreadyRegistered($name): DiException + { + return new self(t('exception.dependency_already_registered', $name), E_ERROR); + } + + /** + * @param $name + * @return DiException + */ + public static function dependencyNotInstantiable($name): DiException + { + return new self(t('exception.dependency_not_instantiable', $name), E_ERROR); + } + + /** + * @param string $name + * @return DiException + */ + public static function invalidAbstractDependency(string $name): DiException + { + return new self(t('exception.invalid_abstract_dependency', $name), E_ERROR); + } + + /** + * @param string $chain + * @return DiException + */ + public static function circularDependency(string $chain): DiException + { + return new self("Circular dependency detected: $chain", 0); + } } diff --git a/tests/Unit/Di/DiTest.php b/tests/Unit/Di/DiTest.php index 06f01b787..4fefae2ac 100644 --- a/tests/Unit/Di/DiTest.php +++ b/tests/Unit/Di/DiTest.php @@ -2,6 +2,7 @@ namespace Quantum\Controllers { + use Quantum\Service\DummyServiceInterface; use Quantum\View\Factories\ViewFactory; use Quantum\Router\RouteController; use Quantum\Http\Request; @@ -13,20 +14,44 @@ public function index(Request $request, Response $response, ViewFactory $view) { // method body } + + public function handleService(DummyServiceInterface $service) + { + return $service; + } } } namespace Quantum\Service { - class DummyService extends QtService + interface DummyServiceInterface {} + + class DummyService extends QtService implements DummyServiceInterface + { + + } + + class CircularDependencyA { + public function __construct(CircularDependencyB $b) + { + } + } + class CircularDependencyB + { + public function __construct(CircularDependencyA $a) + { + } } } namespace Quantum\Tests\Unit\Di { + use Quantum\Service\DummyServiceInterface; use Quantum\Controllers\TestDiController; + use Quantum\Service\CircularDependencyA; + use Quantum\Service\CircularDependencyB; use Quantum\View\Factories\ViewFactory; use Quantum\Di\Exceptions\DiException; use Quantum\Tests\Unit\AppTestCase; @@ -44,6 +69,9 @@ class DiTest extends AppTestCase public function setUp(): void { parent::setUp(); + + Di::reset(); + Di::registerDependencies(); } public function testDiRegisterDependency() @@ -53,6 +81,32 @@ public function testDiRegisterDependency() $this->assertInstanceOf(Setup::class, Di::get(Setup::class)); } + public function testDiAttemptingToRegisterAlreadyRegisteredDependency(): void + { + $this->expectException(DiException::class); + + $this->expectExceptionMessage('exception.dependency_already_registered'); + + Di::register(Setup::class); + Di::register(Setup::class); + } + + public function testDiAttemptingToRegisterNonExistentClass(): void + { + $this->expectException(DiException::class); + $this->expectExceptionMessage('exception.dependency_not_instantiable'); + + Di::register('NonExistentClass'); + } + + public function testDiAttemptingToRegisterNonExistentAbstract(): void + { + $this->expectException(DiException::class); + $this->expectExceptionMessage('exception.invalid_abstract_dependency'); + + Di::register(DummyService::class, 'NonExistentInterface'); + } + public function testDiIsRegistered() { $this->assertFalse(Di::isRegistered(DummyService::class)); @@ -62,6 +116,15 @@ public function testDiIsRegistered() $this->assertTrue(Di::isRegistered(DummyService::class)); } + public function testDiAbstractToConcreteBinding() + { + Di::register(DummyService::class, DummyServiceInterface::class); + + $instance = Di::get(DummyServiceInterface::class); + + $this->assertInstanceOf(DummyService::class, $instance); + } + public function testDiGetCoreDependencies() { $this->assertInstanceOf(Loader::class, Di::get(Loader::class)); @@ -71,7 +134,7 @@ public function testDiGetCoreDependencies() $this->assertInstanceOf(Response::class, Di::get(Response::class)); } - public function testDiGetNotRegisteredDependency() + public function testDiAttemptingToGetNotRegisteredDependency() { $this->assertInstanceOf(Loader::class, Di::get(Loader::class)); @@ -82,6 +145,22 @@ public function testDiGetNotRegisteredDependency() Di::get(DiException::class); } + public function testDiCircularDependencyDetectedAtResolve(): void + { + $this->expectException(DiException::class); + + $this->expectExceptionMessage( + 'Circular dependency detected: ' . CircularDependencyA::class . + ' -> ' . CircularDependencyB::class . + ' -> ' . CircularDependencyA::class + ); + + Di::register(CircularDependencyA::class); + Di::register(CircularDependencyB::class); + + Di::create(CircularDependencyA::class); + } + public function testDiGetReturnsSingleton() { Di::register(DummyService::class); @@ -129,6 +208,19 @@ public function testDiAutowire() $this->assertInstanceOf(Response::class, $params[1]); } + public function testDiAutowireWithAbstract() + { + Di::register(DummyService::class, DummyServiceInterface::class); + + $controller = new TestDiController(); + + $params = Di::autowire([$controller, 'handleService']); + + $this->assertInstanceOf(DummyServiceInterface::class, $params[0]); + + $this->assertInstanceOf(DummyService::class, $params[0]); + } + public function testDiReset() { Di::register(DummyService::class); From 98c1467e061030d2e20d0c2d30ffbf0a5431a394 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 4 Sep 2025 12:59:20 +0400 Subject: [PATCH 2/5] Configuring scrutinizer to use composer 2 --- .scrutinizer.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .scrutinizer.yml diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 000000000..a0026739e --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,11 @@ +build: + environment: + php: + version: 7.4 + dependencies: + before: + - curl -sS https://getcomposer.org/installer | php -- --2 + - mv composer.phar /usr/local/bin/composer + tests: + override: + - php-scrutinizer-run \ No newline at end of file From af76e508543dbea056a2c047eafea882dd8bba1c Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 4 Sep 2025 13:21:32 +0400 Subject: [PATCH 3/5] Configuring the path --- .scrutinizer.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.scrutinizer.yml b/.scrutinizer.yml index a0026739e..bec86a6cd 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -5,7 +5,8 @@ build: dependencies: before: - curl -sS https://getcomposer.org/installer | php -- --2 - - mv composer.phar /usr/local/bin/composer + - mv composer.phar composer + - export PATH="$PWD:$PATH" tests: override: - php-scrutinizer-run \ No newline at end of file From 086c34a45a7f195a008a1f3a6685b9a8a7c53a50 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:30:47 +0400 Subject: [PATCH 4/5] Making sure the `$concrete` is not null --- src/Di/Di.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Di/Di.php b/src/Di/Di.php index ca908d785..9b1f168f5 100644 --- a/src/Di/Di.php +++ b/src/Di/Di.php @@ -235,7 +235,7 @@ private static function resolveParameter(ReflectionParameter $param, ?array &$ar $concrete = self::$dependencies[$type] ?? $type; - if (self::instantiable($concrete)) { + if ($concrete && self::instantiable($concrete)) { return self::create($concrete); } From 6ff2a55d48c6be7ab8699abd32e6ed0581231a47 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:39:34 +0400 Subject: [PATCH 5/5] Type hinting method arguments --- src/Di/Exceptions/DiException.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Di/Exceptions/DiException.php b/src/Di/Exceptions/DiException.php index 8f0945463..56a88b003 100644 --- a/src/Di/Exceptions/DiException.php +++ b/src/Di/Exceptions/DiException.php @@ -26,21 +26,25 @@ class DiException extends BaseException * @param string $name * @return DiException */ - public static function dependencyNotRegistered($name): DiException + public static function dependencyNotRegistered(string $name): DiException { return new self(t('exception.dependency_not_registered', $name), E_ERROR); } - public static function dependencyAlreadyRegistered($name): DiException + /** + * @param string $name + * @return DiException + */ + public static function dependencyAlreadyRegistered(string $name): DiException { return new self(t('exception.dependency_already_registered', $name), E_ERROR); } /** - * @param $name + * @param string $name * @return DiException */ - public static function dependencyNotInstantiable($name): DiException + public static function dependencyNotInstantiable(string $name): DiException { return new self(t('exception.dependency_not_instantiable', $name), E_ERROR); }