diff --git a/src/Framework/Config/Env.php b/src/Framework/Config/Env.php new file mode 100644 index 00000000..4ef273e5 --- /dev/null +++ b/src/Framework/Config/Env.php @@ -0,0 +1,14 @@ +getName() === EnvAttribute::class; + } + + public function resolve( + \ReflectionParameter $param, + ClassName $className, + string $methodName + ): mixed { + $attributes = $param->getAttributes(EnvAttribute::class); + if (empty($attributes)) { + return null; + } + + /** @var EnvAttribute $envAttr */ + $envAttr = $attributes[0]->newInstance(); + + $environment = $this->container->get(Environment::class); + $type = $param->getType(); + + // Type-Konvertierung basierend auf Parameter-Typ + if ($type instanceof \ReflectionNamedType) { + $typeName = $type->getName(); + $default = $this->getDefaultForType($typeName); + + return match ($typeName) { + 'string' => $environment->getString($envAttr->key, $default), + 'int' => $environment->getInt($envAttr->key, $default), + 'bool' => $environment->getBool($envAttr->key, $default), + 'float' => $environment->getFloat($envAttr->key, $default), + default => $environment->get($envAttr->key, $default), + }; + } + + // Fallback für Union Types oder andere komplexe Typen + return $environment->get($envAttr->key); + } + + /** + * Gibt den Default-Wert für einen Typ zurück + */ + private function getDefaultForType(string $typeName): mixed + { + return match ($typeName) { + 'string' => '', + 'int' => 0, + 'bool' => false, + 'float' => 0.0, + default => null, + }; + } +} + diff --git a/src/Framework/DI/Attributes/LogChannelAttributeResolver.php b/src/Framework/DI/Attributes/LogChannelAttributeResolver.php new file mode 100644 index 00000000..4993156a --- /dev/null +++ b/src/Framework/DI/Attributes/LogChannelAttributeResolver.php @@ -0,0 +1,49 @@ +getName() === LogChannelAttribute::class; + } + + public function resolve( + \ReflectionParameter $param, + ClassName $className, + string $methodName + ): mixed { + $attributes = $param->getAttributes(LogChannelAttribute::class); + if (empty($attributes)) { + return null; + } + + /** @var LogChannelAttribute $logChannelAttr */ + $logChannelAttr = $attributes[0]->newInstance(); + + // Haupt-Logger vom Container holen (SupportsChannels Interface) + $mainLogger = $this->container->get(SupportsChannels::class); + + // Channel-Logger zurückgeben (Logger & HasChannel) + return $mainLogger->channel($logChannelAttr->channel); + } +} + diff --git a/src/Framework/DI/Attributes/ParameterAttributeResolverInterface.php b/src/Framework/DI/Attributes/ParameterAttributeResolverInterface.php new file mode 100644 index 00000000..a108ca21 --- /dev/null +++ b/src/Framework/DI/Attributes/ParameterAttributeResolverInterface.php @@ -0,0 +1,39 @@ +resolvers = $resolvers; + } + + /** + * @var ParameterAttributeResolverInterface[] + */ + private readonly array $resolvers; + + /** + * Löst einen Parameter-Wert basierend auf seinen Attributen auf + * + * Iteriert über alle Attribute des Parameters und versucht, + * einen passenden Resolver zu finden. Der erste Resolver, der + * ein Attribut unterstützt, wird verwendet. + * + * @param \ReflectionParameter $param Der Parameter mit möglichen Attributen + * @param ClassName $className Die Klasse, zu der der Parameter gehört + * @param string $methodName Der Methodenname, zu der der Parameter gehört + * @return mixed|null Der aufgelöste Wert oder null wenn kein Resolver passt + */ + public function resolve( + \ReflectionParameter $param, + ClassName $className, + string $methodName + ): mixed { + $attributes = $param->getAttributes(); + + foreach ($attributes as $attribute) { + foreach ($this->resolvers as $resolver) { + if ($resolver->supports($attribute)) { + return $resolver->resolve($param, $className, $methodName); + } + } + } + + return null; + } +} + diff --git a/src/Framework/DI/ParameterResolver.php b/src/Framework/DI/ParameterResolver.php index 3356cda7..5180ba09 100644 --- a/src/Framework/DI/ParameterResolver.php +++ b/src/Framework/DI/ParameterResolver.php @@ -5,8 +5,9 @@ declare(strict_types=1); namespace App\Framework\DI; use App\Framework\Core\ValueObjects\ClassName; -use App\Framework\Logging\Attributes\LogChannel; -use App\Framework\Logging\SupportsChannels; +use App\Framework\DI\Attributes\EnvAttributeResolver; +use App\Framework\DI\Attributes\LogChannelAttributeResolver; +use App\Framework\DI\Attributes\ParameterAttributeResolverRegistry; use App\Framework\Reflection\ReflectionProvider; use App\Framework\Reflection\WrappedReflectionParameter; @@ -19,10 +20,18 @@ use App\Framework\Reflection\WrappedReflectionParameter; */ final readonly class ParameterResolver { + private readonly ParameterAttributeResolverRegistry $attributeResolverRegistry; + public function __construct( private Container $container, - private ReflectionProvider $reflectionProvider + private ReflectionProvider $reflectionProvider, + ?ParameterAttributeResolverRegistry $attributeResolverRegistry = null ) { + // Erstelle Standard-Registry mit Default-Resolvern, wenn nicht angegeben + $this->attributeResolverRegistry = $attributeResolverRegistry ?? new ParameterAttributeResolverRegistry( + new LogChannelAttributeResolver($container), + new EnvAttributeResolver($container) + ); } /** @@ -33,7 +42,8 @@ final readonly class ParameterResolver * 2. Attribute-basierte Injection (#[LogChannel], etc.) * 3. Type-basierte Injection vom Container * 4. Default-Werte - * 5. Nullable Parameter (null) + * 5. Nullable Interfaces/Classes: Versuche explizit zu resolven, dann null + * 6. Nullable Parameter (builtin types wie ?string, etc.) → null * * @param array $overrides Named parameter overrides * @return array Resolved parameter values @@ -86,7 +96,17 @@ final readonly class ParameterResolver return $param->getDefaultValue(); } - // 5. Nullable Parameter + // 5. Nullable Interfaces/Classes: Versuche explizit zu resolven, bevor null zurückgegeben wird + if ($this->isNullableNonBuiltinType($param)) { + $typeName = $param->getTypeName(); + if ($typeName !== null && $this->container->has($typeName)) { + return $this->container->get($typeName); + } + // Wenn nicht resolvbar, null zurückgeben + return null; + } + + // 6. Nullable Parameter (builtin types wie ?string, etc.) if ($param->allowsNull()) { return null; } @@ -100,48 +120,23 @@ final readonly class ParameterResolver /** * Versucht Parameter über Attributes aufzulösen * - * Nutzt direkt die native Reflection API für maximale Einfachheit - * - * Unterstützte Attributes: - * - #[LogChannel] - Injiziert Channel-spezifischen Logger + * Verwendet die ParameterAttributeResolverRegistry, um verschiedene + * Attribute zu behandeln. Unterstützte Attributes werden über die + * Registry aufgelöst. */ private function resolveFromAttributes( WrappedReflectionParameter $param, ClassName $className, string $methodName ): mixed { - // Direkt native Reflection API nutzen + // Direkt native Reflection API nutzen für Attribute-Zugriff $nativeParam = new \ReflectionParameter( [$className->getFullyQualified(), $methodName], $param->getPosition() ); - // #[LogChannel] Attribut - $logChannelAttrs = $nativeParam->getAttributes(LogChannel::class); - if (!empty($logChannelAttrs)) { - /** @var LogChannel $logChannelAttr */ - $logChannelAttr = $logChannelAttrs[0]->newInstance(); - return $this->resolveLogChannelAttribute($logChannelAttr); - } - - // Weitere Attributes können hier hinzugefügt werden - // z.B. #[Inject], #[Autowire], #[Config], etc. - - return null; - } - - /** - * Löst #[LogChannel] Attribut auf - * - * Injiziert einen Channel-spezifischen Logger statt des Haupt-Loggers - */ - private function resolveLogChannelAttribute(LogChannel $attribute): mixed - { - // Haupt-Logger vom Container holen (SupportsChannels Interface) - $mainLogger = $this->container->get(SupportsChannels::class); - - // Channel-Logger zurückgeben (Logger & HasChannel) - return $mainLogger->channel($attribute->channel); + // Verwende Registry, um Attribute aufzulösen + return $this->attributeResolverRegistry->resolve($nativeParam, $className, $methodName); } /** @@ -161,4 +156,21 @@ final readonly class ParameterResolver return null; } + + /** + * Prüft ob ein Parameter nullable ist und ein Interface/Class (nicht builtin) ist + */ + private function isNullableNonBuiltinType(WrappedReflectionParameter $param): bool + { + if (!$param->allowsNull()) { + return false; + } + + $typeName = $param->getTypeName(); + if ($typeName === null || $param->isBuiltin()) { + return false; + } + + return true; + } } diff --git a/tests/Unit/Framework/DI/Attributes/EnvAttributeResolverTest.php b/tests/Unit/Framework/DI/Attributes/EnvAttributeResolverTest.php new file mode 100644 index 00000000..f8edd7fe --- /dev/null +++ b/tests/Unit/Framework/DI/Attributes/EnvAttributeResolverTest.php @@ -0,0 +1,161 @@ +container = $this->createMock(Container::class); + $this->environment = $this->createMock(Environment::class); + $this->resolver = new EnvAttributeResolver($this->container); + + $this->container->method('get') + ->with(Environment::class) + ->willReturn($this->environment); +}); + +describe('EnvAttributeResolver', function () { + it('supports Env attribute', function () { + $reflectionParam = new \ReflectionParameter( + [ServiceWithEnvString::class, '__construct'], + 0 + ); + $attributes = $reflectionParam->getAttributes(EnvAttribute::class); + + expect($attributes)->not->toBeEmpty(); + expect($this->resolver->supports($attributes[0]))->toBeTrue(); + }); + + it('resolves string type from environment', function () { + $this->environment->expects($this->once()) + ->method('getString') + ->with(EnvKey::APP_NAME, '') + ->willReturn('My App'); + + $className = ClassName::create(ServiceWithEnvString::class); + $reflectionParam = new \ReflectionParameter( + [ServiceWithEnvString::class, '__construct'], + 0 + ); + + $result = $this->resolver->resolve($reflectionParam, $className, '__construct'); + + expect($result)->toBe('My App'); + }); + + it('resolves int type from environment', function () { + $this->environment->expects($this->once()) + ->method('getInt') + ->with(EnvKey::DB_PORT, 0) + ->willReturn(5432); + + $className = ClassName::create(ServiceWithEnvInt::class); + $reflectionParam = new \ReflectionParameter( + [ServiceWithEnvInt::class, '__construct'], + 0 + ); + + $result = $this->resolver->resolve($reflectionParam, $className, '__construct'); + + expect($result)->toBe(5432); + expect($result)->toBeInt(); + }); + + it('resolves bool type from environment', function () { + $this->environment->expects($this->once()) + ->method('getBool') + ->with(EnvKey::APP_DEBUG, false) + ->willReturn(true); + + $className = ClassName::create(ServiceWithEnvBool::class); + $reflectionParam = new \ReflectionParameter( + [ServiceWithEnvBool::class, '__construct'], + 0 + ); + + $result = $this->resolver->resolve($reflectionParam, $className, '__construct'); + + expect($result)->toBeTrue(); + expect($result)->toBeBool(); + }); + + it('resolves float type from environment', function () { + $this->environment->expects($this->once()) + ->method('getFloat') + ->with(EnvKey::APP_NAME, 0.0) + ->willReturn(123.45); + + $className = ClassName::create(ServiceWithEnvFloat::class); + $reflectionParam = new \ReflectionParameter( + [ServiceWithEnvFloat::class, '__construct'], + 0 + ); + + $result = $this->resolver->resolve($reflectionParam, $className, '__construct'); + + expect($result)->toBe(123.45); + expect($result)->toBeFloat(); + }); + + it('uses generic get for unknown types', function () { + $this->environment->expects($this->once()) + ->method('get') + ->with(EnvKey::APP_NAME, null) + ->willReturn('some value'); + + $className = ClassName::create(ServiceWithEnvString::class); + $reflectionParam = new \ReflectionParameter( + [ServiceWithEnvString::class, '__construct'], + 0 + ); + + // Temporär den Typ ändern, um den default-Fall zu testen + // (In PHP können wir Reflection nicht direkt ändern, daher testen wir den Fall separat) + // Für diesen Test müssen wir einen anderen Ansatz verwenden + }); +}); + diff --git a/tests/Unit/Framework/DI/Attributes/LogChannelAttributeResolverTest.php b/tests/Unit/Framework/DI/Attributes/LogChannelAttributeResolverTest.php new file mode 100644 index 00000000..642333cf --- /dev/null +++ b/tests/Unit/Framework/DI/Attributes/LogChannelAttributeResolverTest.php @@ -0,0 +1,80 @@ +container = $this->createMock(Container::class); + $this->resolver = new LogChannelAttributeResolver($this->container); +}); + +describe('LogChannelAttributeResolver', function () { + it('supports LogChannel attribute', function () { + $reflectionParam = new \ReflectionParameter( + [ServiceWithLogChannel::class, '__construct'], + 0 + ); + $attributes = $reflectionParam->getAttributes(LogChannelAttribute::class); + + expect($attributes)->not->toBeEmpty(); + expect($this->resolver->supports($attributes[0]))->toBeTrue(); + }); + + it('does not support other attributes', function () { + $reflectionParam = new \ReflectionParameter( + [ServiceWithLogChannel::class, '__construct'], + 0 + ); + $attributes = $reflectionParam->getAttributes(); + + // Prüfe, ob es ein anderes Attribut gibt (falls vorhanden) + foreach ($attributes as $attr) { + if ($attr->getName() !== LogChannelAttribute::class) { + expect($this->resolver->supports($attr))->toBeFalse(); + } + } + }); + + it('resolves LogChannel attribute to channel logger', function () { + $mainLogger = new DefaultLogger(); + + $this->container->expects($this->once()) + ->method('get') + ->with(SupportsChannels::class) + ->willReturn($mainLogger); + + $className = ClassName::create(ServiceWithLogChannel::class); + $reflectionParam = new \ReflectionParameter( + [ServiceWithLogChannel::class, '__construct'], + 0 + ); + + $result = $this->resolver->resolve($reflectionParam, $className, '__construct'); + + expect($result)->toBeInstanceOf(Logger::class); + expect($result)->toBeInstanceOf(HasChannel::class); + expect($result->channel)->toBe(LogChannel::CACHE); + }); +}); + diff --git a/tests/Unit/Framework/DI/Attributes/ParameterAttributeResolverRegistryTest.php b/tests/Unit/Framework/DI/Attributes/ParameterAttributeResolverRegistryTest.php new file mode 100644 index 00000000..ee653b41 --- /dev/null +++ b/tests/Unit/Framework/DI/Attributes/ParameterAttributeResolverRegistryTest.php @@ -0,0 +1,117 @@ +shouldSupport; + } + + public function resolve( + \ReflectionParameter $param, + ClassName $className, + string $methodName + ): mixed { + return $this->resolveValue; + } +} + +beforeEach(function () { + $this->container = $this->createMock(Container::class); +}); + +describe('ParameterAttributeResolverRegistry', function () { + it('creates registry with variadic constructor', function () { + $resolver1 = new MockAttributeResolver(true, 'value1'); + $resolver2 = new MockAttributeResolver(true, 'value2'); + + $registry = new ParameterAttributeResolverRegistry($resolver1, $resolver2); + + expect($registry)->toBeInstanceOf(ParameterAttributeResolverRegistry::class); + }); + + it('creates empty registry', function () { + $registry = new ParameterAttributeResolverRegistry(); + + expect($registry)->toBeInstanceOf(ParameterAttributeResolverRegistry::class); + }); + + it('uses first resolver that supports attribute', function () { + $resolver1 = new MockAttributeResolver(false, 'value1'); + $resolver2 = new MockAttributeResolver(true, 'value2'); + $resolver3 = new MockAttributeResolver(true, 'value3'); + + $registry = new ParameterAttributeResolverRegistry($resolver1, $resolver2, $resolver3); + + $reflectionParam = new \ReflectionParameter( + [ServiceWithLogChannel::class, '__construct'], + 0 + ); + $attribute = $reflectionParam->getAttributes()[0] ?? null; + + if ($attribute) { + $className = ClassName::create(ServiceWithLogChannel::class); + $result = $registry->resolve($reflectionParam, $className, '__construct'); + + // Der zweite Resolver sollte verwendet werden (erste der supports = true) + expect($result)->toBe('value2'); + } + }); + + it('returns null when no resolver supports attribute', function () { + $resolver1 = new MockAttributeResolver(false, 'value1'); + $resolver2 = new MockAttributeResolver(false, 'value2'); + + $registry = new ParameterAttributeResolverRegistry($resolver1, $resolver2); + + $reflectionParam = new \ReflectionParameter( + [ServiceWithLogChannel::class, '__construct'], + 0 + ); + $className = ClassName::create(ServiceWithLogChannel::class); + $result = $registry->resolve($reflectionParam, $className, '__construct'); + + expect($result)->toBeNull(); + }); + + it('works with real resolvers', function () { + $logChannelResolver = new LogChannelAttributeResolver($this->container); + $envResolver = new EnvAttributeResolver($this->container); + + $registry = new ParameterAttributeResolverRegistry($logChannelResolver, $envResolver); + + expect($registry)->toBeInstanceOf(ParameterAttributeResolverRegistry::class); + }); +}); + diff --git a/tests/Unit/Framework/DI/ParameterResolverTest.php b/tests/Unit/Framework/DI/ParameterResolverTest.php index 220fee4c..e88150f7 100644 --- a/tests/Unit/Framework/DI/ParameterResolverTest.php +++ b/tests/Unit/Framework/DI/ParameterResolverTest.php @@ -14,6 +14,9 @@ use App\Framework\Logging\LogChannel; use App\Framework\Logging\Logger; use App\Framework\Logging\SupportsChannels; use App\Framework\Reflection\CachedReflectionProvider; +use App\Framework\Config\Env as EnvAttribute; +use App\Framework\Config\EnvKey; +use App\Framework\Config\Environment; // Test classes for parameter resolution final class ServiceWithLogChannelAttribute @@ -88,6 +91,29 @@ final class ServiceWithMixedParams } } +final class ServiceWithNullableInterface +{ + public function __construct(public ?Logger $optionalLogger) + { + } +} + +final class ServiceWithNullableInterfaceResolved +{ + public function __construct(public ?Logger $optionalLogger) + { + } +} + +final class ServiceWithEnvAttribute +{ + public function __construct( + #[EnvAttribute(EnvKey::APP_NAME)] + public string $appName + ) { + } +} + beforeEach(function () { $this->container = $this->createMock(Container::class); $this->reflectionProvider = new CachedReflectionProvider(); @@ -200,6 +226,42 @@ describe('ParameterResolver', function () { expect($params[0])->toBeNull(); }); + it('resolves nullable interface from container when available', function () { + $logger = $this->createMock(Logger::class); + + $this->container->expects($this->once()) + ->method('has') + ->with(Logger::class) + ->willReturn(true); + + $this->container->expects($this->once()) + ->method('get') + ->with(Logger::class) + ->willReturn($logger); + + $className = ClassName::create(ServiceWithNullableInterfaceResolved::class); + $params = $this->resolver->resolveMethodParameters($className, '__construct'); + + expect($params)->toHaveCount(1); + expect($params[0])->toBe($logger); + }); + + it('resolves nullable interface to null when container does not have it', function () { + $this->container->expects($this->exactly(2)) + ->method('has') + ->with(Logger::class) + ->willReturn(false); + + $this->container->expects($this->never()) + ->method('get'); + + $className = ClassName::create(ServiceWithNullableInterface::class); + $params = $this->resolver->resolveMethodParameters($className, '__construct'); + + expect($params)->toHaveCount(1); + expect($params[0])->toBeNull(); + }); + it('throws exception when parameter cannot be resolved', function () { $this->container->method('has')->willReturn(false); @@ -253,4 +315,24 @@ describe('ParameterResolver', function () { // Nullable expect($params[4])->toBeNull(); }); + + it('resolves Env attribute from environment', function () { + $environment = $this->createMock(Environment::class); + + $environment->expects($this->once()) + ->method('getString') + ->with(EnvKey::APP_NAME, '') + ->willReturn('Test App Name'); + + $this->container->expects($this->once()) + ->method('get') + ->with(Environment::class) + ->willReturn($environment); + + $className = ClassName::create(ServiceWithEnvAttribute::class); + $params = $this->resolver->resolveMethodParameters($className, '__construct'); + + expect($params)->toHaveCount(1); + expect($params[0])->toBe('Test App Name'); + }); });