From dcb1b8fa080a2baa722bedac1a716858df17c371 Mon Sep 17 00:00:00 2001 From: Julien Duseyau Date: Mon, 6 Oct 2025 23:28:00 +0200 Subject: [PATCH] Implement strict only mode support in container compilation and resolution --- src/Container/ArgonContainer.php | 19 ++- src/Container/Compiler/CompilationContext.php | 3 +- .../Compiler/CompilationContextFactory.php | 7 +- src/Container/Compiler/ContainerCompiler.php | 14 ++- .../Compiler/CoreContainerGenerator.php | 110 ++++++++++++++++-- src/Container/ServiceResolver.php | 10 +- .../Compiler/ContainerCompilerTest.php | 54 ++++++++- tests/integration/ServiceContainerTest.php | 23 ++++ tests/unit/Container/ServiceResolverTest.php | 46 ++++++++ 9 files changed, 262 insertions(+), 24 deletions(-) diff --git a/src/Container/ArgonContainer.php b/src/Container/ArgonContainer.php index 4b89605..4bea27e 100644 --- a/src/Container/ArgonContainer.php +++ b/src/Container/ArgonContainer.php @@ -45,6 +45,7 @@ class ArgonContainer implements ContainerInterface private readonly ServiceBinderInterface $binder; private readonly ParameterStoreInterface $parameterStore; private readonly InterceptorRegistryInterface $interceptors; + private bool $strictMode = false; /** * @throws ContainerException @@ -61,8 +62,10 @@ public function __construct( ?ServiceProviderRegistryInterface $providers = null, ?ServiceResolverInterface $serviceResolver = null, ?ArgumentResolverInterface $argumentResolver = null, - ?ServiceBinderInterface $binder = null + ?ServiceBinderInterface $binder = null, + bool $strictMode = false ) { + $this->strictMode = $strictMode; $argumentMap = $argumentMap ?? new ArgumentMap(); $this->parameterStore = $parameters ?? new ParameterStore(); $this->interceptors = $interceptors ?? new InterceptorRegistry(); @@ -84,9 +87,16 @@ public function __construct( $this->binder, $reflectionCache, $this->interceptors, - $argumentResolver + $argumentResolver, + $strictMode ); + if ($serviceResolver instanceof ServiceResolverInterface && $serviceResolver !== $this->serviceResolver) { + if (method_exists($serviceResolver, 'setStrictMode')) { + $serviceResolver->setStrictMode($strictMode); + } + } + $argumentResolver->setServiceResolver($this->serviceResolver); if (method_exists($this->interceptors, 'setResolver')) { @@ -107,6 +117,11 @@ public function getContextualBindings(): ContextualBindingsInterface return $this->contextualBindings; } + public function isStrictMode(): bool + { + return $this->strictMode; + } + /** * @template TGet of object * @param class-string|string $id diff --git a/src/Container/Compiler/CompilationContext.php b/src/Container/Compiler/CompilationContext.php index 0c1c746..0013b0e 100644 --- a/src/Container/Compiler/CompilationContext.php +++ b/src/Container/Compiler/CompilationContext.php @@ -15,7 +15,8 @@ public function __construct( public readonly ArgonContainer $container, public readonly PhpFile $file, public readonly PhpNamespace $namespace, - public readonly ClassType $class + public readonly ClassType $class, + public readonly bool $strictMode ) { } } diff --git a/src/Container/Compiler/CompilationContextFactory.php b/src/Container/Compiler/CompilationContextFactory.php index 87b82d9..7380453 100644 --- a/src/Container/Compiler/CompilationContextFactory.php +++ b/src/Container/Compiler/CompilationContextFactory.php @@ -6,6 +6,7 @@ use Maduser\Argon\Container\ArgonContainer; use Maduser\Argon\Container\Exceptions\ContainerException; +use Maduser\Argon\Container\Exceptions\NotFoundException; use Nette\PhpGenerator\PhpFile; final class CompilationContextFactory @@ -13,7 +14,8 @@ final class CompilationContextFactory public function create( ArgonContainer $container, string $namespace, - string $className + string $className, + bool $strictMode = false ): CompilationContext { $file = new PhpFile(); $file->setStrictTypes(); @@ -21,10 +23,11 @@ public function create( $namespaceGen = $file->addNamespace($namespace); $namespaceGen->addUse(ArgonContainer::class); $namespaceGen->addUse(ContainerException::class); + $namespaceGen->addUse(NotFoundException::class); $class = $namespaceGen->addClass($className); $class->setExtends(ArgonContainer::class); - return new CompilationContext($container, $file, $namespaceGen, $class); + return new CompilationContext($container, $file, $namespaceGen, $class, $strictMode); } } diff --git a/src/Container/Compiler/ContainerCompiler.php b/src/Container/Compiler/ContainerCompiler.php index 3685a5c..aa5cc38 100644 --- a/src/Container/Compiler/ContainerCompiler.php +++ b/src/Container/Compiler/ContainerCompiler.php @@ -39,9 +39,17 @@ public function __construct( * @throws ContainerException * @throws ReflectionException */ - public function compile(string $filePath, string $className, string $namespace = 'App\\Compiled'): void - { - $context = $this->contextFactory->create($this->container, $namespace, $className); + public function compile( + string $filePath, + string $className, + string $namespace = 'App\\Compiled', + ?bool $strictMode = null + ): void { + $strictMode ??= method_exists($this->container, 'isStrictMode') + ? $this->container->isStrictMode() + : false; + + $context = $this->contextFactory->create($this->container, $namespace, $className, $strictMode); $this->coreGenerator->generate($context); $this->serviceDefinitionGenerator->generate($context); diff --git a/src/Container/Compiler/CoreContainerGenerator.php b/src/Container/Compiler/CoreContainerGenerator.php index 3e6898f..6a28cea 100644 --- a/src/Container/Compiler/CoreContainerGenerator.php +++ b/src/Container/Compiler/CoreContainerGenerator.php @@ -5,16 +5,20 @@ namespace Maduser\Argon\Container\Compiler; use Maduser\Argon\Container\ArgonContainer; +use Maduser\Argon\Container\Exceptions\NotFoundException; use Nette\PhpGenerator\ClassType; final class CoreContainerGenerator { + private bool $strictMode = false; + public function __construct(private readonly ArgonContainer $container) { } public function generate(CompilationContext $context): void { + $this->strictMode = $context->strictMode; $class = $context->class; $this->generateConstructor($class); @@ -33,7 +37,11 @@ public function generate(CompilationContext $context): void private function generateConstructor(ClassType $class): void { $constructor = $class->addMethod('__construct')->setPublic(); - $constructor->addBody('parent::__construct();'); + if ($this->strictMode) { + $constructor->addBody('parent::__construct(strictMode: true);'); + } else { + $constructor->addBody('parent::__construct();'); + } $parameterStore = $this->container->getParameters()->all(); if (!empty($parameterStore)) { @@ -105,19 +113,37 @@ private function generateInterceptorMethods(ClassType $class): void private function generateGetMethod(ClassType $class): void { $method = $class->addMethod('get') - ->setReturnType('object') - ->setBody(<<<'PHP' + ->setReturnType('object'); + + $strictBody = <<<'PHP' $instance = $this->applyPreInterceptors($id, $args); if ($instance !== null) { return $instance; } - + + if (!isset($this->serviceMap[$id])) { + throw new NotFoundException($id, 'compiled'); + } + + $instance = $this->{$this->serviceMap[$id]}($args); + + return $this->applyPostInterceptors($instance); + PHP; + + $lenientBody = <<<'PHP' + $instance = $this->applyPreInterceptors($id, $args); + if ($instance !== null) { + return $instance; + } + $instance = isset($this->serviceMap[$id]) ? $this->{$this->serviceMap[$id]}($args) : parent::get($id, $args); - + return $this->applyPostInterceptors($instance); - PHP); + PHP; + + $method->setBody($this->strictMode ? $strictBody : $lenientBody); $method->addParameter('id')->setType('string'); $method->addParameter('args')->setType('array')->setDefaultValue([]); @@ -162,9 +188,13 @@ private function generateGetTaggedMetaMethod(ClassType $class): void private function generateHasMethod(ClassType $class): void { + $body = $this->strictMode + ? 'return isset($this->serviceMap[$id]);' + : 'return isset($this->serviceMap[$id]) || parent::has($id);'; + $class->addMethod('has') ->setReturnType('bool') - ->setBody('return isset($this->serviceMap[$id]) || parent::has($id);') + ->setBody($body) ->addParameter('id')->setType('string'); } @@ -177,7 +207,7 @@ private function generateInvokeMethod(ClassType $class): void $invoke->addParameter('target')->setType('callable|object|array|string'); $invoke->addParameter('arguments')->setType('array')->setDefaultValue([]); - $invoke->setBody(<<<'PHP' + $lenientBody = <<<'PHP' if (is_callable($target) && !is_array($target)) { $reflection = new \ReflectionFunction($target); $instance = null; @@ -230,7 +260,54 @@ private function generateInvokeMethod(ClassType $class): void } return $reflection->invokeArgs($instance, $params); - PHP); + PHP; + + $strictBody = <<<'PHP' + if (is_callable($target) && !is_array($target)) { + $reflection = new \ReflectionFunction($target); + $instance = null; + } elseif (is_array($target) && count($target) === 2) { + [$controller, $method] = $target; + $instance = is_object($controller) ? $controller : $this->get($controller); + $reflection = new \ReflectionMethod($instance, $method); + } else { + $instance = is_object($target) ? $target : $this->get($target); + $reflection = new \ReflectionMethod($instance, '__invoke'); + } + + $params = []; + + foreach ($reflection->getParameters() as $param) { + $name = $param->getName(); + $type = $param->getType()?->getName(); + + if (array_key_exists($name, $arguments)) { + $params[] = $arguments[$name]; + continue; + } + + if ($type && $this->has($type)) { + $params[] = $this->get($type); + continue; + } + + if ($param->isDefaultValueAvailable()) { + $params[] = $param->getDefaultValue(); + continue; + } + + if ($param->allowsNull()) { + $params[] = null; + continue; + } + + throw new NotFoundException($name, 'compiled invoke'); + } + + return $reflection->invokeArgs($instance, $params); + PHP; + + $invoke->setBody($this->strictMode ? $strictBody : $lenientBody); } private function generateInvokeServiceMethod(ClassType $class): void @@ -238,7 +315,17 @@ private function generateInvokeServiceMethod(ClassType $class): void $method = $class->addMethod('invokeServiceMethod') ->setPrivate() ->setReturnType('mixed') - ->setBody(<<<'PHP' + ->setBody($this->strictMode + ? <<<'PHP' + $compiledMethod = $this->buildCompiledInvokerMethodName($serviceId, $method); + + if (method_exists($this, $compiledMethod)) { + return $this->{$compiledMethod}($args); + } + + throw new NotFoundException($serviceId, 'compiled invoke'); + PHP + : <<<'PHP' $compiledMethod = $this->buildCompiledInvokerMethodName($serviceId, $method); if (method_exists($this, $compiledMethod)) { @@ -246,7 +333,8 @@ private function generateInvokeServiceMethod(ClassType $class): void } return $this->invoke([$serviceId, $method], $args); - PHP); + PHP + ); $method->addParameter('serviceId')->setType('string'); $method->addParameter('method')->setType('string'); diff --git a/src/Container/ServiceResolver.php b/src/Container/ServiceResolver.php index 9acbac3..3b5f9c8 100644 --- a/src/Container/ServiceResolver.php +++ b/src/Container/ServiceResolver.php @@ -43,10 +43,16 @@ public function __construct( private readonly ServiceBinderInterface $binder, private readonly ReflectionCacheInterface $reflectionCache, private readonly InterceptorRegistryInterface $interceptors, - private readonly ArgumentResolverInterface $argumentResolver + private readonly ArgumentResolverInterface $argumentResolver, + private bool $strictMode = false ) { } + public function setStrictMode(bool $strict): void + { + $this->strictMode = $strict; + } + /** * Resolves a service by ID or class name. * @@ -83,7 +89,7 @@ public function resolve(string $id, array $args = []): object if ($descriptor !== null) { $instance = $this->resolveFromDescriptor($id, $descriptor, $args); - } elseif (class_exists($id)) { + } elseif (!$this->strictMode && class_exists($id)) { $instance = $this->resolveUnregistered($id, $args); } else { $requestedBy = self::$resolutionStack[count(self::$resolutionStack) - 2] ?? 'unknown'; diff --git a/tests/integration/Compiler/ContainerCompilerTest.php b/tests/integration/Compiler/ContainerCompilerTest.php index aa633ff..d3b116b 100644 --- a/tests/integration/Compiler/ContainerCompilerTest.php +++ b/tests/integration/Compiler/ContainerCompilerTest.php @@ -46,8 +46,11 @@ class ContainerCompilerTest extends TestCase * @throws ContainerException * @throws ReflectionException */ - private function compileAndLoadContainer(ArgonContainer $container, string $className): ArgonContainer - { + private function compileAndLoadContainer( + ArgonContainer $container, + string $className, + ?bool $strictMode = null + ): ArgonContainer { $namespace = 'Tests\\Integration\\Compiler'; $file = __DIR__ . "/../../resources/cache/{$className}.php"; @@ -56,7 +59,7 @@ private function compileAndLoadContainer(ArgonContainer $container, string $clas } $compiler = new ContainerCompiler($container); - $compiler->compile($file, $className, $namespace); + $compiler->compile($file, $className, $namespace, $strictMode); require_once $file; @@ -488,6 +491,51 @@ public function testCompiledInterceptorsResolveDependenciesViaContainer(): void $this->assertTrue($logger->intercepted); } + /** + * @throws ContainerException + * @throws NotFoundException + * @throws ReflectionException + */ + public function testStrictCompiledContainerDisallowsUnregisteredServices(): void + { + $container = new ArgonContainer(strictMode: true); + + $compiled = $this->compileAndLoadContainer($container, 'StrictDisallowsAutowiring', strictMode: true); + + $this->expectException(NotFoundException::class); + $compiled->get(Logger::class); + } + + /** + * @throws ContainerException + * @throws NotFoundException + * @throws ReflectionException + */ + public function testStrictCompiledContainerResolvesRegisteredServices(): void + { + $container = new ArgonContainer(strictMode: true); + $container->set(Logger::class); + + $compiled = $this->compileAndLoadContainer($container, 'StrictResolvesRegistered', strictMode: true); + + $this->assertInstanceOf(Logger::class, $compiled->get(Logger::class)); + } + + /** + * @throws ContainerException + * @throws NotFoundException + * @throws ReflectionException + */ + public function testStrictCompiledInvokeThrowsForMissingDependency(): void + { + $container = new ArgonContainer(strictMode: true); + + $compiled = $this->compileAndLoadContainer($container, 'StrictInvokeFails', strictMode: true); + + $this->expectException(NotFoundException::class); + $compiled->invoke([ServiceWithDependency::class, 'doSomething']); + } + /** * @throws ContainerException * @throws NotFoundException diff --git a/tests/integration/ServiceContainerTest.php b/tests/integration/ServiceContainerTest.php index 1ca76e9..a8a71a6 100644 --- a/tests/integration/ServiceContainerTest.php +++ b/tests/integration/ServiceContainerTest.php @@ -104,6 +104,29 @@ public function testInvokeWithInvalidCallableThrows(): void $this->container->invoke('this_is_not_callable'); } + /** + * @throws ContainerException + */ + public function testStrictModeDisallowsUnregisteredAutowiring(): void + { + $container = new ArgonContainer(strictMode: true); + + $this->expectException(NotFoundException::class); + $container->get(Logger::class); + } + + /** + * @throws ContainerException + * @throws NotFoundException + */ + public function testStrictModeResolvesRegisteredService(): void + { + $container = new ArgonContainer(strictMode: true); + $container->set(Logger::class); + + $this->assertInstanceOf(Logger::class, $container->get(Logger::class)); + } + /** * @throws ContainerException */ diff --git a/tests/unit/Container/ServiceResolverTest.php b/tests/unit/Container/ServiceResolverTest.php index 54538d7..30274f5 100644 --- a/tests/unit/Container/ServiceResolverTest.php +++ b/tests/unit/Container/ServiceResolverTest.php @@ -465,4 +465,50 @@ public function testResolveClassCatchesInstantiationFailure(): void $this->invokeMethod($this->resolver, 'resolveClass', [$className]); } + + /** + * @throws NotFoundException + */ + public function testStrictModeThrowsForUnregisteredClass(): void + { + $resolver = new ServiceResolver( + $this->binder, + $this->reflectionCache, + $this->interceptors, + $this->parameterResolver, + true + ); + + $this->binder->method('getDescriptor')->willReturn(null); + + $this->expectException(NotFoundException::class); + $resolver->resolve(stdClass::class); + } + + /** + * @throws ContainerException + * @throws NotFoundException + */ + public function testStrictModeResolvesBoundService(): void + { + $descriptor = $this->createMock(ServiceDescriptorInterface::class); + $descriptor->method('getConcrete')->willReturn(fn() => new stdClass()); + $descriptor->method('isShared')->willReturn(false); + $descriptor->method('getInstance')->willReturn(null); + + $this->binder->method('getDescriptor')->willReturn($descriptor); + $this->interceptors->method('matchPost')->willReturnCallback(fn(object $instance): object => $instance); + + $resolver = new ServiceResolver( + $this->binder, + $this->reflectionCache, + $this->interceptors, + $this->parameterResolver, + true + ); + + $result = $resolver->resolve('boundService'); + + $this->assertInstanceOf(stdClass::class, $result); + } }