From d0e950f188854bce30b1658d24845a9eacbb2b1b Mon Sep 17 00:00:00 2001 From: Julien Duseyau Date: Wed, 8 Oct 2025 20:23:19 +0200 Subject: [PATCH 1/3] Refactor strict mode handling in ContainerCompiler and update interceptor resolver injection --- src/Container/ArgonContainer.php | 10 +------- src/Container/Compiler/ContainerCompiler.php | 8 +++---- .../InterceptorRegistryInterface.php | 7 ++++++ src/Container/ServiceResolver.php | 19 +++++++-------- .../Compiler/ContainerCompilerTest.php | 23 ++++++++++++++++++- .../Compiler/Mocks/PrimitiveService.php | 12 ++++++++++ .../Support/CallableInvokerTest.php | 5 ++++ tests/unit/Container/ServiceContainerTest.php | 7 ++++++ 8 files changed, 66 insertions(+), 25 deletions(-) create mode 100644 tests/integration/Compiler/Mocks/PrimitiveService.php diff --git a/src/Container/ArgonContainer.php b/src/Container/ArgonContainer.php index 4bea27e..baf5ae2 100644 --- a/src/Container/ArgonContainer.php +++ b/src/Container/ArgonContainer.php @@ -91,17 +91,9 @@ public function __construct( $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')) { - $this->interceptors->setResolver($this->serviceResolver); - } + $this->interceptors->setResolver($this->serviceResolver); $this->invoker = $invoker ?? new CallableInvoker( $this->serviceResolver, diff --git a/src/Container/Compiler/ContainerCompiler.php b/src/Container/Compiler/ContainerCompiler.php index aa5cc38..d6d76ea 100644 --- a/src/Container/Compiler/ContainerCompiler.php +++ b/src/Container/Compiler/ContainerCompiler.php @@ -43,11 +43,11 @@ public function compile( string $filePath, string $className, string $namespace = 'App\\Compiled', - ?bool $strictMode = null + bool $strictMode = false ): void { - $strictMode ??= method_exists($this->container, 'isStrictMode') - ? $this->container->isStrictMode() - : false; + if (!$strictMode) { + $strictMode = $this->container->isStrictMode(); + } $context = $this->contextFactory->create($this->container, $namespace, $className, $strictMode); diff --git a/src/Container/Contracts/InterceptorRegistryInterface.php b/src/Container/Contracts/InterceptorRegistryInterface.php index 56043d3..b212a54 100644 --- a/src/Container/Contracts/InterceptorRegistryInterface.php +++ b/src/Container/Contracts/InterceptorRegistryInterface.php @@ -4,6 +4,8 @@ namespace Maduser\Argon\Container\Contracts; +use Maduser\Argon\Container\Contracts\ServiceResolverInterface; + /** * Registry for pre- and post-resolution interceptors. */ @@ -42,4 +44,9 @@ public function matchPost(object $instance): object; * Match and return a pre-resolution interceptor (or null). */ public function matchPre(string $id, array &$parameters = []): ?object; + + /** + * Injects the service resolver used to instantiate interceptors. + */ + public function setResolver(ServiceResolverInterface $resolver): void; } diff --git a/src/Container/ServiceResolver.php b/src/Container/ServiceResolver.php index 3b5f9c8..9ae84e2 100644 --- a/src/Container/ServiceResolver.php +++ b/src/Container/ServiceResolver.php @@ -15,7 +15,6 @@ use Maduser\Argon\Container\Exceptions\NotFoundException; use Maduser\Argon\Container\Support\DebugTrace; use ReflectionException; -use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; use Throwable; @@ -33,7 +32,7 @@ final class ServiceResolver implements ServiceResolverInterface private array $resolving = []; /** - * Static stack of current resolution path, for debugging/contextual error reporting. + * Static stack of the current resolution path, for debugging/contextual error reporting. * * @var string[] */ @@ -48,11 +47,6 @@ public function __construct( ) { } - public function setStrictMode(bool $strict): void - { - $this->strictMode = $strict; - } - /** * Resolves a service by ID or class name. * @@ -64,11 +58,9 @@ public function setStrictMode(bool $strict): void * * @throws ContainerException * @throws NotFoundException - * @throws ReflectionException * * We're going to make one single exception here, * IMAO this more likely PossiblyPsalmsProblem - * @psalm-suppress PossiblyUnusedReturnValue */ public function resolve(string $id, array $args = []): object { @@ -81,7 +73,6 @@ public function resolve(string $id, array $args = []): object $result = $this->interceptors->matchPre($id, $args); if ($result !== null) { $this->removeFromResolving($id); - array_pop(self::$resolutionStack); return $result; } @@ -98,6 +89,12 @@ public function resolve(string $id, array $args = []): object $this->removeFromResolving($id); return $instance; + } catch (ReflectionException $e) { + $this->removeFromResolving($id); + throw ContainerException::fromServiceId( + $id, + 'Reflection error: ' . $e->getMessage() + ); } finally { array_pop(self::$resolutionStack); } @@ -206,7 +203,7 @@ private function resolveFromFactory(string $id, ServiceDescriptorInterface $desc } /** - * Resolves a class that is not registered in the container. + * Resolves a class not registered in the container. * * @param class-string $id * diff --git a/tests/integration/Compiler/ContainerCompilerTest.php b/tests/integration/Compiler/ContainerCompilerTest.php index d3b116b..fb73eb3 100644 --- a/tests/integration/Compiler/ContainerCompilerTest.php +++ b/tests/integration/Compiler/ContainerCompilerTest.php @@ -23,6 +23,7 @@ use Tests\Integration\Compiler\Mocks\LoggerInterceptor; use Tests\Integration\Compiler\Mocks\Mailer; use Tests\Integration\Compiler\Mocks\MailerFactory; +use Tests\Integration\Compiler\Mocks\PrimitiveService; use Tests\Integration\Compiler\Mocks\ServiceWithDependency; use Tests\Integration\Compiler\Mocks\SomeInterface; use Tests\Integration\Compiler\Mocks\TestServiceWithMultipleParams; @@ -59,7 +60,8 @@ private function compileAndLoadContainer( } $compiler = new ContainerCompiler($container); - $compiler->compile($file, $className, $namespace, $strictMode); + $effectiveStrictMode = $strictMode ?? $container->isStrictMode(); + $compiler->compile($file, $className, $namespace, $effectiveStrictMode); require_once $file; @@ -536,6 +538,25 @@ public function testStrictCompiledInvokeThrowsForMissingDependency(): void $compiled->invoke([ServiceWithDependency::class, 'doSomething']); } + /** + * @throws ContainerException + * @throws NotFoundException + * @throws ReflectionException + */ + public function testCompiledContainerInjectsPrimitiveArguments(): void + { + $container = new ArgonContainer(); + $container->set(PrimitiveService::class, args: [ + 'path' => '/tmp/profiles', + ]); + + $compiled = $this->compileAndLoadContainer($container, 'PrimitiveServiceCompiled'); + + $service = $compiled->get(PrimitiveService::class); + + $this->assertSame('/tmp/profiles', $service->path); + } + /** * @throws ContainerException * @throws NotFoundException diff --git a/tests/integration/Compiler/Mocks/PrimitiveService.php b/tests/integration/Compiler/Mocks/PrimitiveService.php new file mode 100644 index 0000000..503ff8b --- /dev/null +++ b/tests/integration/Compiler/Mocks/PrimitiveService.php @@ -0,0 +1,12 @@ +createMock(InterceptorRegistryInterface::class); + $interceptors->expects($this->once()) + ->method('setResolver') + ->with($this->isInstanceOf(ServiceResolverInterface::class)); $interceptors->expects($this->once()) ->method('allPost') ->willReturn(['PostInterceptor']); @@ -296,6 +300,9 @@ public function testGetPostInterceptorsReturnsCorrectList(): void public function testGetPreInterceptorsReturnsCorrectList(): void { $interceptors = $this->createMock(InterceptorRegistryInterface::class); + $interceptors->expects($this->once()) + ->method('setResolver') + ->with($this->isInstanceOf(ServiceResolverInterface::class)); $interceptors->expects($this->once()) ->method('allPre') ->willReturn(['PreInterceptor']); From fda46fc8afe6097b7d5a046eb685bfe3218e57f0 Mon Sep 17 00:00:00 2001 From: Julien Duseyau Date: Wed, 8 Oct 2025 22:40:59 +0200 Subject: [PATCH 2/3] Add method invocation mapping and contextual binding support in CoreContainerGenerator --- .../Compiler/CoreContainerGenerator.php | 229 +++++++++++++++++- .../Compiler/ParameterExpressionResolver.php | 27 ++- .../Compiler/ContainerCompilerTest.php | 29 +++ .../Mocks/ContextualLoggerHandler.php | 13 + 4 files changed, 283 insertions(+), 15 deletions(-) create mode 100644 tests/integration/Mocks/ContextualLoggerHandler.php diff --git a/src/Container/Compiler/CoreContainerGenerator.php b/src/Container/Compiler/CoreContainerGenerator.php index 6a28cea..b5359af 100644 --- a/src/Container/Compiler/CoreContainerGenerator.php +++ b/src/Container/Compiler/CoreContainerGenerator.php @@ -5,8 +5,12 @@ namespace Maduser\Argon\Container\Compiler; use Maduser\Argon\Container\ArgonContainer; +use Maduser\Argon\Container\Exceptions\ContainerException; use Maduser\Argon\Container\Exceptions\NotFoundException; use Nette\PhpGenerator\ClassType; +use ReflectionClass; +use ReflectionException; +use ReflectionMethod; final class CoreContainerGenerator { @@ -23,6 +27,7 @@ public function generate(CompilationContext $context): void $this->generateConstructor($class); $this->generateCoreProperties($class); + $this->generateMethodInvocationMap($class); $this->generateInterceptorMethods($class); $this->generateHasMethod($class); $this->generateGetMethod($class); @@ -48,6 +53,30 @@ private function generateConstructor(ClassType $class): void $formatted = var_export($parameterStore, true); $constructor->addBody("\$this->getParameters()->setStore({$formatted});"); } + + $contextualBindings = $this->container->getContextualBindings()->getBindings(); + if (!empty($contextualBindings)) { + $constructor->addBody('$contextual = $this->getContextualBindings();'); + + foreach ($contextualBindings as $consumer => $dependencies) { + foreach ($dependencies as $dependency => $concrete) { + if ($concrete instanceof \Closure) { + throw new ContainerException(sprintf( + 'Cannot compile contextual binding for "%s" -> "%s": closures are not supported in compiled containers.', + $consumer, + $dependency + )); + } + + $constructor->addBody(sprintf( + '$contextual->bind(%s, %s, %s);', + var_export($consumer, true), + var_export($dependency, true), + var_export($concrete, true) + )); + } + } + } } private function generateCoreProperties(ClassType $class): void @@ -66,6 +95,122 @@ private function generateCoreProperties(ClassType $class): void )); } + private function generateMethodInvocationMap(ClassType $class): void + { + $resolver = new ParameterExpressionResolver( + $this->container, + $this->container->getContextualBindings() + ); + + $bindings = $this->container->getContextualBindings()->getBindings(); + $methodMap = []; + + foreach ($bindings as $consumer => $_) { + if (!str_contains($consumer, '::')) { + continue; + } + + [$service, $method] = explode('::', $consumer, 2); + + if (!class_exists($service)) { + throw new ContainerException(sprintf( + 'Contextual binding references missing class "%s".', + $service + )); + } + + $reflectionClass = new ReflectionClass($service); + + if (!$reflectionClass->hasMethod($method)) { + throw new ContainerException(sprintf( + 'Contextual binding references missing method "%s::%s".', + $service, + $method + )); + } + + $reflectionMethod = $reflectionClass->getMethod($method); + $compiledMethodName = $this->buildCompiledMethodInvokerName($service, $method); + + $this->generateMethodInvoker( + $class, + $resolver, + $service, + $reflectionMethod, + $consumer, + $compiledMethodName + ); + + $methodMap[$consumer] = $compiledMethodName; + } + + $class->addProperty('compiledMethodMap') + ->setPrivate() + ->setValue($methodMap); + } + + private function generateMethodInvoker( + ClassType $class, + ParameterExpressionResolver $resolver, + string $service, + ReflectionMethod $method, + string $contextKey, + string $compiledMethodName + ): void { + $expressions = $resolver->resolveMethodParameters( + $method, + $service, + $contextKey, + '$args' + ); + + $arguments = implode( + ",\n", + $expressions + ); + + $compiled = $class->addMethod($compiledMethodName) + ->setPrivate() + ->setReturnType('mixed'); + + $instanceParam = $compiled->addParameter('instance') + ->setType('object') + ->setNullable(true); + $instanceParam->setDefaultValue(null); + + $compiled->addParameter('args') + ->setType('array') + ->setDefaultValue([]); + + if ($method->isStatic()) { + $call = '\\' . ltrim($service, '\\') . '::' . $method->getName(); + $compiled->setBody(sprintf( + 'return %s(%s);', + $call, + trim($arguments) === '' ? '' : "\n{$arguments}\n" + )); + return; + } + + $body = '$target = $instance ?? $this->get(' . var_export($service, true) . ");\n"; + $call = '$target->' . $method->getName(); + $body .= sprintf( + 'return %s(%s);', + $call, + trim($arguments) === '' ? '' : "\n{$arguments}\n" + ); + + $compiled->setBody($body); + } + + private function buildCompiledMethodInvokerName(string $service, string $method): string + { + $sanitizedService = preg_replace('/[^A-Za-z0-9_]/', '_', $service); + $sanitizedMethod = preg_replace('/[^A-Za-z0-9_]/', '_', $method); + + return 'call_' . $sanitizedService . '__' . $sanitizedMethod; + } + private function generateInterceptorMethods(ClassType $class): void { $pre = $class->addMethod('applyPreInterceptors'); @@ -208,16 +353,45 @@ private function generateInvokeMethod(ClassType $class): void $invoke->addParameter('arguments')->setType('array')->setDefaultValue([]); $lenientBody = <<<'PHP' - if (is_callable($target) && !is_array($target)) { - $reflection = new \ReflectionFunction($target); - $instance = null; - } elseif (is_array($target) && count($target) === 2) { + if (is_array($target) && count($target) === 2) { [$controller, $method] = $target; + $contextKey = (is_object($controller) ? get_class($controller) : $controller) . '::' . $method; + $instance = is_object($controller) ? $controller : null; + + if (isset($this->compiledMethodMap[$contextKey])) { + return $this->{$this->compiledMethodMap[$contextKey]}($instance, $arguments); + } + $instance = is_object($controller) ? $controller : $this->get($controller); $reflection = new \ReflectionMethod($instance, $method); + } elseif (is_string($target) && str_contains($target, '::')) { + [$controller, $method] = explode('::', $target, 2); + $contextKey = $controller . '::' . $method; + + if (isset($this->compiledMethodMap[$contextKey])) { + return $this->{$this->compiledMethodMap[$contextKey]}(null, $arguments); + } + + $instance = $this->get($controller); + $reflection = new \ReflectionMethod($instance, $method); } else { - $instance = is_object($target) ? $target : $this->get($target); - $reflection = new \ReflectionMethod($instance, '__invoke'); + 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); + $contextKey = get_class($instance) . '::__invoke'; + + if (isset($this->compiledMethodMap[$contextKey])) { + return $this->{$this->compiledMethodMap[$contextKey]}($instance, $arguments); + } + + $reflection = new \ReflectionMethod($instance, '__invoke'); + } } $params = []; @@ -256,23 +430,52 @@ private function generateInvokeMethod(ClassType $class): void continue; } - throw new \RuntimeException("Unable to resolve parameter '{$name}' for '{$reflection->getName()}'"); + throw new \RuntimeException('Unable to resolve parameter ' . $name . ' for ' . $reflection->getName()); } return $reflection->invokeArgs($instance, $params); PHP; $strictBody = <<<'PHP' - if (is_callable($target) && !is_array($target)) { - $reflection = new \ReflectionFunction($target); - $instance = null; - } elseif (is_array($target) && count($target) === 2) { + if (is_array($target) && count($target) === 2) { [$controller, $method] = $target; + $contextKey = (is_object($controller) ? get_class($controller) : $controller) . '::' . $method; + $instance = is_object($controller) ? $controller : null; + + if (isset($this->compiledMethodMap[$contextKey])) { + return $this->{$this->compiledMethodMap[$contextKey]}($instance, $arguments); + } + $instance = is_object($controller) ? $controller : $this->get($controller); $reflection = new \ReflectionMethod($instance, $method); + } elseif (is_string($target) && str_contains($target, '::')) { + [$controller, $method] = explode('::', $target, 2); + $contextKey = $controller . '::' . $method; + + if (isset($this->compiledMethodMap[$contextKey])) { + return $this->{$this->compiledMethodMap[$contextKey]}(null, $arguments); + } + + $instance = $this->get($controller); + $reflection = new \ReflectionMethod($instance, $method); } else { - $instance = is_object($target) ? $target : $this->get($target); - $reflection = new \ReflectionMethod($instance, '__invoke'); + 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); + $contextKey = get_class($instance) . '::__invoke'; + + if (isset($this->compiledMethodMap[$contextKey])) { + return $this->{$this->compiledMethodMap[$contextKey]}($instance, $arguments); + } + + $reflection = new \ReflectionMethod($instance, '__invoke'); + } } $params = []; diff --git a/src/Container/Compiler/ParameterExpressionResolver.php b/src/Container/Compiler/ParameterExpressionResolver.php index 8edf9ae..aa1e77d 100644 --- a/src/Container/Compiler/ParameterExpressionResolver.php +++ b/src/Container/Compiler/ParameterExpressionResolver.php @@ -9,6 +9,7 @@ use Maduser\Argon\Container\Exceptions\ContainerException; use ReflectionClass; use ReflectionException; +use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; @@ -55,7 +56,8 @@ public function resolveConstructorArguments( public function resolveParameter( ReflectionParameter $parameter, string $serviceId, - string $argsVar = '$args' + string $argsVar = '$args', + ?string $contextId = null ): string { $name = $parameter->getName(); $type = $parameter->getType(); @@ -64,7 +66,7 @@ public function resolveParameter( $runtime = "{$argsVar}[" . var_export($name, true) . "]"; $fallbacks = []; $declaringClass = $parameter->getDeclaringClass(); - $context = $declaringClass?->getName() ?? $serviceId; + $context = $contextId ?? ($declaringClass?->getName() ?? $serviceId); if ($typeName !== null && $this->contextualBindings->has($context, $typeName)) { $target = $this->contextualBindings->get($context, $typeName); @@ -116,4 +118,25 @@ class_exists($value) return $runtime; } + + /** + * @param ReflectionMethod $method + * @return list + * + * @throws ContainerException + */ + public function resolveMethodParameters( + ReflectionMethod $method, + string $serviceId, + string $contextId, + string $argsVar = '$args' + ): array { + $resolved = []; + + foreach ($method->getParameters() as $param) { + $resolved[] = $this->resolveParameter($param, $serviceId, $argsVar, $contextId); + } + + return $resolved; + } } diff --git a/tests/integration/Compiler/ContainerCompilerTest.php b/tests/integration/Compiler/ContainerCompilerTest.php index fb73eb3..87f119d 100644 --- a/tests/integration/Compiler/ContainerCompilerTest.php +++ b/tests/integration/Compiler/ContainerCompilerTest.php @@ -35,6 +35,7 @@ use Tests\Integration\Mocks\Logger as AutowireLogger; use Tests\Integration\Mocks\LoggerInterface; use Tests\Integration\Mocks\NeedsLogger; +use Tests\Integration\Mocks\ContextualLoggerHandler; use Tests\Integration\Mocks\NeedsNullable; use Tests\Integration\Mocks\PreArgOverride; use Tests\Integration\Mocks\SimpleService; @@ -449,6 +450,34 @@ public function testCompiledInvokeResolvesUnregisteredConcreteParameters(): void $this->assertSame('from-invoker', $result); } + /** + * @throws ContainerException + * @throws NotFoundException + * @throws ReflectionException + */ + public function testCompiledInvokeHonorsMethodLevelContextualBindings(): void + { + $container = new ArgonContainer(); + + $container->set(CustomLogger::class); + $container->set(ContextualLoggerHandler::class); + + $container->for(ContextualLoggerHandler::class . '::handle') + ->set(LoggerInterface::class, CustomLogger::class); + + $runtimeResult = $container->invoke([ContextualLoggerHandler::class, 'handle']); + $this->assertSame('[custom] invoked', $runtimeResult); + + $compiled = $this->compileAndLoadContainer( + $container, + 'testCompiledInvokeHonorsMethodLevelContextualBindings' + ); + + $compiledResult = $compiled->invoke([ContextualLoggerHandler::class, 'handle']); + + $this->assertSame('[custom] invoked', $compiledResult); + } + /** * @throws ContainerException diff --git a/tests/integration/Mocks/ContextualLoggerHandler.php b/tests/integration/Mocks/ContextualLoggerHandler.php new file mode 100644 index 0000000..2c312f2 --- /dev/null +++ b/tests/integration/Mocks/ContextualLoggerHandler.php @@ -0,0 +1,13 @@ +log('invoked'); + } +} From 837bd689a6c38aa9e982e0a07caba60059149036 Mon Sep 17 00:00:00 2001 From: Julien Duseyau Date: Thu, 9 Oct 2025 01:43:45 +0200 Subject: [PATCH 3/3] Add noReflection support to container compilation and method invocation --- src/Container/Compiler/CompilationContext.php | 3 +- .../Compiler/CompilationContextFactory.php | 5 +- src/Container/Compiler/ContainerCompiler.php | 15 +- .../Compiler/CoreContainerGenerator.php | 142 +++++++++++++++-- .../Exceptions/NotFoundException.php | 26 +++- .../Compiler/ContainerCompilerTest.php | 147 +++++++++++++++++- .../Compiler/Mocks/StaticService.php | 13 ++ .../Mocks/ContextualLoggerHandler.php | 1 + .../Container/AbstractServiceProviderTest.php | 7 +- .../Container/InterceptorRegistryTest.php | 36 +++++ .../Container/Mocks/ExistingButIncomplete.php | 10 ++ tests/unit/Container/ServiceResolverTest.php | 33 ++++ 12 files changed, 412 insertions(+), 26 deletions(-) create mode 100644 tests/integration/Compiler/Mocks/StaticService.php create mode 100644 tests/unit/Container/Mocks/ExistingButIncomplete.php diff --git a/src/Container/Compiler/CompilationContext.php b/src/Container/Compiler/CompilationContext.php index 0013b0e..d61261e 100644 --- a/src/Container/Compiler/CompilationContext.php +++ b/src/Container/Compiler/CompilationContext.php @@ -16,7 +16,8 @@ public function __construct( public readonly PhpFile $file, public readonly PhpNamespace $namespace, public readonly ClassType $class, - public readonly bool $strictMode + public readonly bool $strictMode, + public readonly bool $noReflection ) { } } diff --git a/src/Container/Compiler/CompilationContextFactory.php b/src/Container/Compiler/CompilationContextFactory.php index 7380453..39e0e9e 100644 --- a/src/Container/Compiler/CompilationContextFactory.php +++ b/src/Container/Compiler/CompilationContextFactory.php @@ -15,7 +15,8 @@ public function create( ArgonContainer $container, string $namespace, string $className, - bool $strictMode = false + bool $strictMode = false, + bool $noReflection = false ): CompilationContext { $file = new PhpFile(); $file->setStrictTypes(); @@ -28,6 +29,6 @@ public function create( $class = $namespaceGen->addClass($className); $class->setExtends(ArgonContainer::class); - return new CompilationContext($container, $file, $namespaceGen, $class, $strictMode); + return new CompilationContext($container, $file, $namespaceGen, $class, $strictMode, $noReflection); } } diff --git a/src/Container/Compiler/ContainerCompiler.php b/src/Container/Compiler/ContainerCompiler.php index d6d76ea..9d84467 100644 --- a/src/Container/Compiler/ContainerCompiler.php +++ b/src/Container/Compiler/ContainerCompiler.php @@ -43,13 +43,24 @@ public function compile( string $filePath, string $className, string $namespace = 'App\\Compiled', - bool $strictMode = false + bool $strictMode = false, + ?bool $noReflection = null ): void { if (!$strictMode) { $strictMode = $this->container->isStrictMode(); } - $context = $this->contextFactory->create($this->container, $namespace, $className, $strictMode); + if ($noReflection === null) { + $noReflection = $strictMode; + } + + $context = $this->contextFactory->create( + $this->container, + $namespace, + $className, + $strictMode, + $noReflection + ); $this->coreGenerator->generate($context); $this->serviceDefinitionGenerator->generate($context); diff --git a/src/Container/Compiler/CoreContainerGenerator.php b/src/Container/Compiler/CoreContainerGenerator.php index b5359af..175635d 100644 --- a/src/Container/Compiler/CoreContainerGenerator.php +++ b/src/Container/Compiler/CoreContainerGenerator.php @@ -4,10 +4,12 @@ namespace Maduser\Argon\Container\Compiler; +use Closure; use Maduser\Argon\Container\ArgonContainer; use Maduser\Argon\Container\Exceptions\ContainerException; use Maduser\Argon\Container\Exceptions\NotFoundException; use Nette\PhpGenerator\ClassType; +use Nette\PhpGenerator\Method; use ReflectionClass; use ReflectionException; use ReflectionMethod; @@ -15,14 +17,19 @@ final class CoreContainerGenerator { private bool $strictMode = false; + private bool $noReflection = false; public function __construct(private readonly ArgonContainer $container) { } + /** + * @throws ContainerException + */ public function generate(CompilationContext $context): void { $this->strictMode = $context->strictMode; + $this->noReflection = $context->noReflection; $class = $context->class; $this->generateConstructor($class); @@ -39,6 +46,9 @@ public function generate(CompilationContext $context): void $this->generateBuildCompiledInvokerMethodName($class); } + /** + * @throws ContainerException + */ private function generateConstructor(ClassType $class): void { $constructor = $class->addMethod('__construct')->setPublic(); @@ -55,14 +65,19 @@ private function generateConstructor(ClassType $class): void } $contextualBindings = $this->container->getContextualBindings()->getBindings(); + + /** + * @var array> $contextualBindings + */ if (!empty($contextualBindings)) { $constructor->addBody('$contextual = $this->getContextualBindings();'); foreach ($contextualBindings as $consumer => $dependencies) { foreach ($dependencies as $dependency => $concrete) { - if ($concrete instanceof \Closure) { + if ($concrete instanceof Closure) { throw new ContainerException(sprintf( - 'Cannot compile contextual binding for "%s" -> "%s": closures are not supported in compiled containers.', + 'Cannot compile contextual binding for "%s" -> "%s": ' . + 'closures are not supported in compiled containers.', $consumer, $dependency )); @@ -95,6 +110,9 @@ private function generateCoreProperties(ClassType $class): void )); } + /** + * @throws ContainerException + */ private function generateMethodInvocationMap(ClassType $class): void { $resolver = new ParameterExpressionResolver( @@ -102,7 +120,12 @@ private function generateMethodInvocationMap(ClassType $class): void $this->container->getContextualBindings() ); + $bindings = $this->container->getContextualBindings()->getBindings(); + + /** + * @var array> $bindings + */ $methodMap = []; foreach ($bindings as $consumer => $_) { @@ -110,7 +133,15 @@ private function generateMethodInvocationMap(ClassType $class): void continue; } - [$service, $method] = explode('::', $consumer, 2); + /** @var list $parts */ + $parts = explode('::', $consumer, 2); + if (count($parts) !== 2) { + // @codeCoverageIgnoreStart + continue; // Safety check for Psalm... + // @codeCoverageIgnoreEnd + } + + [$service, $method] = $parts; if (!class_exists($service)) { throw new ContainerException(sprintf( @@ -149,6 +180,9 @@ private function generateMethodInvocationMap(ClassType $class): void ->setValue($methodMap); } + /** + * @throws ContainerException + */ private function generateMethodInvoker( ClassType $class, ParameterExpressionResolver $resolver, @@ -183,12 +217,7 @@ private function generateMethodInvoker( ->setDefaultValue([]); if ($method->isStatic()) { - $call = '\\' . ltrim($service, '\\') . '::' . $method->getName(); - $compiled->setBody(sprintf( - 'return %s(%s);', - $call, - trim($arguments) === '' ? '' : "\n{$arguments}\n" - )); + $this->generateStaticInvoker($service, $method, $compiled, $arguments); return; } @@ -352,6 +381,58 @@ private function generateInvokeMethod(ClassType $class): void $invoke->addParameter('target')->setType('callable|object|array|string'); $invoke->addParameter('arguments')->setType('array')->setDefaultValue([]); + if ($this->noReflection) { + $noReflectionBody = <<<'PHP' + if (is_array($target) && count($target) === 2) { + [$controller, $method] = $target; + $contextKey = (is_object($controller) ? get_class($controller) : $controller) . '::' . $method; + $instance = is_object($controller) ? $controller : null; + + if (isset($this->compiledMethodMap[$contextKey])) { + return $this->{$this->compiledMethodMap[$contextKey]}($instance, $arguments); + } + + throw NotFoundException::forMissingCompiledInvoker($contextKey); + } + + if (is_string($target) && str_contains($target, '::')) { + [$controller, $method] = explode('::', $target, 2); + $contextKey = $controller . '::' . $method; + + if (isset($this->compiledMethodMap[$contextKey])) { + return $this->{$this->compiledMethodMap[$contextKey]}(null, $arguments); + } + + throw NotFoundException::forMissingCompiledInvoker($contextKey); + } + + if (is_object($target)) { + $contextKey = get_class($target) . '::__invoke'; + + if (isset($this->compiledMethodMap[$contextKey])) { + return $this->{$this->compiledMethodMap[$contextKey]}($target, $arguments); + } + + throw NotFoundException::forMissingCompiledInvoker($contextKey); + } + + if (is_string($target) && class_exists($target)) { + $contextKey = $target . '::__invoke'; + + if (isset($this->compiledMethodMap[$contextKey])) { + return $this->{$this->compiledMethodMap[$contextKey]}(null, $arguments); + } + + throw nNotFoundException::forMissingCompiledInvoker($contextKey); + } + + throw NotFoundException::forMissingCompiledInvoker($contextKey); + PHP; + + $invoke->setBody($noReflectionBody); + return; + } + $lenientBody = <<<'PHP' if (is_array($target) && count($target) === 2) { [$controller, $method] = $target; @@ -517,8 +598,25 @@ private function generateInvokeServiceMethod(ClassType $class): void { $method = $class->addMethod('invokeServiceMethod') ->setPrivate() - ->setReturnType('mixed') - ->setBody($this->strictMode + ->setReturnType('mixed'); + + if ($this->noReflection) { + $method->setBody(<<<'PHP' + $compiledMethod = $this->buildCompiledInvokerMethodName($serviceId, $method); + + if (method_exists($this, $compiledMethod)) { + return $this->{$compiledMethod}($args); + } + + $contextKey = $serviceId . '::' . $method; + if (isset($this->compiledMethodMap[$contextKey])) { + return $this->{$this->compiledMethodMap[$contextKey]}(null, $args); + } + + throw new NotFoundException("No compiled service invoker for '{$contextKey}' in no-reflection mode."); + PHP); + } else { + $method->setBody($this->strictMode ? <<<'PHP' $compiledMethod = $this->buildCompiledInvokerMethodName($serviceId, $method); @@ -538,6 +636,7 @@ private function generateInvokeServiceMethod(ClassType $class): void return $this->invoke([$serviceId, $method], $args); PHP ); + } $method->addParameter('serviceId')->setType('string'); $method->addParameter('method')->setType('string'); @@ -560,4 +659,25 @@ private function generateBuildCompiledInvokerMethodName(ClassType $class): void return 'invoke_' . $sanitizedService . '__' . $sanitizedMethod; PHP); } + + /** + * @param string $service + * @param ReflectionMethod $method + * @param Method $compiled + * @param string $arguments + * @return void + */ + public function generateStaticInvoker( + string $service, + ReflectionMethod $method, + Method $compiled, + string $arguments + ): void { + $call = '\\' . ltrim($service, '\\') . '::' . $method->getName(); + $compiled->setBody(sprintf( + 'return %s(%s);', + $call, + trim($arguments) === '' ? '' : "\n$arguments\n" + )); + } } diff --git a/src/Container/Exceptions/NotFoundException.php b/src/Container/Exceptions/NotFoundException.php index c5a64f9..b67882c 100644 --- a/src/Container/Exceptions/NotFoundException.php +++ b/src/Container/Exceptions/NotFoundException.php @@ -13,9 +13,29 @@ */ final class NotFoundException extends Exception implements NotFoundExceptionInterface { - public function __construct(string $serviceId, string $requestedBy = 'unknown') + /** + * @api + * + * @param string $contextKey + * @return self + */ + public static function forMissingCompiledInvoker(string $contextKey): self { - $message = "Service '$serviceId' not found (requested by $requestedBy)."; - parent::__construct($message, 404); + return new self( + $contextKey, + 'compiled invoke', + "No compiled invoker for '{$contextKey}' in strict no-reflection mode." + ); + } + + public function __construct( + string $serviceId, + string $requestedBy = 'unknown', + ?string $message = null + ) { + parent::__construct( + $message ?? "Service '$serviceId' not found (requested by $requestedBy).", + 404 + ); } } diff --git a/tests/integration/Compiler/ContainerCompilerTest.php b/tests/integration/Compiler/ContainerCompilerTest.php index 87f119d..2ddfeb7 100644 --- a/tests/integration/Compiler/ContainerCompilerTest.php +++ b/tests/integration/Compiler/ContainerCompilerTest.php @@ -6,7 +6,9 @@ use ErrorException; use Maduser\Argon\Container\ArgumentMap; +use Maduser\Argon\Container\Compiler\CompilationContextFactory; use Maduser\Argon\Container\Compiler\ContainerCompiler; +use Maduser\Argon\Container\Compiler\CoreContainerGenerator; use Maduser\Argon\Container\Contracts\ReflectionCacheInterface; use Maduser\Argon\Container\Contracts\ServiceDescriptorInterface; use Maduser\Argon\Container\Exceptions\ContainerException; @@ -26,6 +28,7 @@ use Tests\Integration\Compiler\Mocks\PrimitiveService; use Tests\Integration\Compiler\Mocks\ServiceWithDependency; use Tests\Integration\Compiler\Mocks\SomeInterface; +use Tests\Integration\Compiler\Mocks\StaticService; use Tests\Integration\Compiler\Mocks\TestServiceWithMultipleParams; use Tests\Integration\Compiler\Mocks\WithOptionalInterface; use Tests\Integration\Compiler\Mocks\WithOptionalService; @@ -40,6 +43,7 @@ use Tests\Integration\Mocks\PreArgOverride; use Tests\Integration\Mocks\SimpleService; use Tests\Mocks\DummyProvider; +use Tests\Unit\Container\Mocks\ExistingButIncomplete; use Tests\Unit\Container\Mocks\NonInstantiableClass; class ContainerCompilerTest extends TestCase @@ -51,7 +55,8 @@ class ContainerCompilerTest extends TestCase private function compileAndLoadContainer( ArgonContainer $container, string $className, - ?bool $strictMode = null + ?bool $strictMode = null, + ?bool $noReflection = null ): ArgonContainer { $namespace = 'Tests\\Integration\\Compiler'; $file = __DIR__ . "/../../resources/cache/{$className}.php"; @@ -62,7 +67,8 @@ private function compileAndLoadContainer( $compiler = new ContainerCompiler($container); $effectiveStrictMode = $strictMode ?? $container->isStrictMode(); - $compiler->compile($file, $className, $namespace, $effectiveStrictMode); + $effectiveNoReflection = $noReflection ?? $effectiveStrictMode; + $compiler->compile($file, $className, $namespace, $effectiveStrictMode, $effectiveNoReflection); require_once $file; @@ -478,6 +484,61 @@ public function testCompiledInvokeHonorsMethodLevelContextualBindings(): void $this->assertSame('[custom] invoked', $compiledResult); } + /** + * @throws ContainerException + * @throws ReflectionException + * @throws NotFoundException + */ + public function testStrictCompiledInvokeFailsWithoutMethodBinding(): void + { + $container = new ArgonContainer(strictMode: true); + + $container->set(CustomLogger::class); + $container->set(ContextualLoggerHandler::class); + + $compiled = $this->compileAndLoadContainer( + $container, + 'StrictInvokeFailsNoBinding', + strictMode: true, + noReflection: true + ); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage( + "No compiled invoker for '" . ContextualLoggerHandler::class . "::handle' " . + "in strict no-reflection mode." + ); + + $compiled->invoke([ContextualLoggerHandler::class, 'handle']); + } + + /** + * @throws ContainerException + * @throws ReflectionException + * @throws NotFoundException + */ + public function testStrictCompiledInvokeHonorsMethodBinding(): void + { + $container = new ArgonContainer(strictMode: true); + + $container->set(CustomLogger::class); + $container->set(ContextualLoggerHandler::class); + + $container->for(ContextualLoggerHandler::class . '::handle') + ->set(LoggerInterface::class, CustomLogger::class); + + $compiled = $this->compileAndLoadContainer( + $container, + 'StrictInvokeHonorsBinding', + strictMode: true, + noReflection: true + ); + + $result = $compiled->invoke([ContextualLoggerHandler::class, 'handle']); + + $this->assertSame('[custom] invoked', $result); + } + /** * @throws ContainerException @@ -914,4 +975,86 @@ public function testCompilerThrowsForNonInstantiableClass(): void $compiler = new ContainerCompiler($container); $compiler->compile(__DIR__ . '/../../resources/cache/Boom.php', 'Boom'); } + + public function testGenerateConstructorThrowsOnClosureContextualBinding(): void + { + $container = new ArgonContainer(strictMode: true); + $container->getContextualBindings()->bind('Foo', 'Bar', fn () => 'Baz'); + + $contextFactory = new CompilationContextFactory(); + $context = $contextFactory->create($container, 'Some\\Namespace', 'FakeCompiledClass', true, false); + + $generator = new CoreContainerGenerator($container); + + $this->expectException(ContainerException::class); + $this->expectExceptionMessage('closures are not supported in compiled containers'); + + $generator->generate($context); + } + + public function testGenerateMethodInvocationMapThrowsOnMissingClass(): void + { + $container = new ArgonContainer(strictMode: true); + $container->getContextualBindings()->bind('Nope\DoesNotExist::method', 'param', 'SomeService'); + + $contextFactory = new CompilationContextFactory(); + $context = $contextFactory->create($container, 'Test\\Ns', 'ClassWithMissingContext', true, false); + + $generator = new CoreContainerGenerator($container); + + $this->expectException(ContainerException::class); + $this->expectExceptionMessage('Contextual binding references missing class'); + + $generator->generate($context); + } + + public function testGenerateMethodInvocationMapThrowsOnMissingMethod(): void + { + $container = new ArgonContainer(strictMode: true); + $container->getContextualBindings()->bind( + ExistingButIncomplete::class . '::missingMethod', + 'param', + 'ConcreteService' + ); + + $contextFactory = new CompilationContextFactory(); + $context = $contextFactory->create($container, 'Test\\Ns', 'FailOnMissingMethod', true, false); + + $generator = new CoreContainerGenerator($container); + + $this->expectException(ContainerException::class); + $this->expectExceptionMessage('Contextual binding references missing method'); + + $generator->generate($context); + } + + /** + * @throws ReflectionException + * @throws ContainerException + * @throws NotFoundException + */ + public function testStaticMethodCallIsCompiled(): void + { + $container = new ArgonContainer(strictMode: true); + + $container->getContextualBindings()->bind( + StaticService::class . '::sayHello', + 'name', + 'Steve' + ); + + $compiled = $this->compileAndLoadContainer( + $container, + 'CompiledStaticHello', + strictMode: true, + noReflection: true + ); + + $result = $compiled->invoke( + [StaticService::class, 'sayHello'], + ['name' => 'Steve'] + ); + + $this->assertSame('Hello Steve', $result); + } } diff --git a/tests/integration/Compiler/Mocks/StaticService.php b/tests/integration/Compiler/Mocks/StaticService.php new file mode 100644 index 0000000..00e671b --- /dev/null +++ b/tests/integration/Compiler/Mocks/StaticService.php @@ -0,0 +1,13 @@ +log('invoked'); } } diff --git a/tests/unit/Container/AbstractServiceProviderTest.php b/tests/unit/Container/AbstractServiceProviderTest.php index 7200d18..782ca15 100644 --- a/tests/unit/Container/AbstractServiceProviderTest.php +++ b/tests/unit/Container/AbstractServiceProviderTest.php @@ -17,18 +17,15 @@ class AbstractServiceProviderTest extends TestCase public function testBootDoesNothingByDefault(): void { $provider = new class extends AbstractServiceProvider { - public function register(ArgonContainer $container): void - { - // No-op for test - } }; $container = $this->createMock(ArgonContainer::class); // No exception = pass + $provider->register($container); $provider->boot($container); - $this->assertTrue(true); // Just assert we got here + $this->assertTrue(true); // Assert we got here } /** diff --git a/tests/unit/Container/InterceptorRegistryTest.php b/tests/unit/Container/InterceptorRegistryTest.php index ed9e9ac..5daf835 100644 --- a/tests/unit/Container/InterceptorRegistryTest.php +++ b/tests/unit/Container/InterceptorRegistryTest.php @@ -151,4 +151,40 @@ public function testMatchPreReturnsNullIfNoInterceptorMatches(): void $this->assertNull($result); } + + public function testResolvePostInterceptorThrowsIfResolvedIsInvalid(): void + { + $resolver = $this->createMock(\Maduser\Argon\Container\Contracts\ServiceResolverInterface::class); + $resolver->method('resolve')->willReturn(new \stdClass()); + + $registry = new InterceptorRegistry(); + $registry->setResolver($resolver); + $registry->registerPost(StubInterceptor::class); + + $this->expectException(ContainerException::class); + $this->expectExceptionMessage( + "Resolved interceptor must implement PostResolutionInterceptorInterface." + ); + + // force instantiation + $registry->matchPost(new \stdClass()); + } + + public function testResolvePreInterceptorThrowsIfResolvedIsInvalid(): void + { + $resolver = $this->createMock(\Maduser\Argon\Container\Contracts\ServiceResolverInterface::class); + $resolver->method('resolve')->willReturn(new \stdClass()); + + $registry = new InterceptorRegistry(); + $registry->setResolver($resolver); + $registry->registerPre(StubPreInterceptor::class); + + $this->expectException(ContainerException::class); + $this->expectExceptionMessage( + "Resolved interceptor must implement PreResolutionInterceptorInterface." + ); + + // force instantiation + $registry->matchPre('StubMatch'); + } } diff --git a/tests/unit/Container/Mocks/ExistingButIncomplete.php b/tests/unit/Container/Mocks/ExistingButIncomplete.php new file mode 100644 index 0000000..b4192ab --- /dev/null +++ b/tests/unit/Container/Mocks/ExistingButIncomplete.php @@ -0,0 +1,10 @@ +assertInstanceOf(stdClass::class, $result); } + + public function testThrowsContainerExceptionWhenReflectionThrowsInsideDescriptor(): void + { + $binder = $this->createMock(ServiceBinderInterface::class); + $reflectionCache = $this->createMock(ReflectionCacheInterface::class); + $interceptors = $this->createMock(InterceptorRegistryInterface::class); + $argumentResolver = $this->createMock(ArgumentResolverInterface::class); + + $descriptor = $this->createMock(ServiceDescriptorInterface::class); + $descriptor->method('isShared')->willReturn(false); + $descriptor->method('hasFactory')->willReturn(false); + $descriptor->method('getConcrete')->willReturn('FakeClassThatExists'); + + $binder->method('getDescriptor')->willReturn($descriptor); + + $reflectionCache->method('get')->willThrowException( + new \ReflectionException("Mocked reflection failure.") + ); + + $resolver = new ServiceResolver( + $binder, + $reflectionCache, + $interceptors, + $argumentResolver + ); + + $this->expectException(ContainerException::class); + $this->expectExceptionMessage('Reflection error: Mocked reflection failure.'); + + // This will try to resolve via descriptor → concrete class → reflection = boom + $resolver->resolve('SomeServiceId'); + } }