diff --git a/CHANGELOG.md b/CHANGELOG.md index 212f2dfe..4be427d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ See [UPGRADE-4.0.md](UPGRADE-4.0.md) for the migration guide. **Requirements** * [BC BREAK] Requires PHP 8.4+ -* [BC BREAK] **Dependencies** — `ext-tokenizer`, `goaop/parser-reflection` ^4.0.1 || ^5.0.1, `goaop/dissect` ^4.0 and `symfony/finder` ^7.4 || ^8.0. `nikic/php-parser` 5 comes through parser-reflection and is no longer required directly. `symfony/console` ^7.4 || ^8.0 is needed for the `bin/aspect` tool. +* [BC BREAK] **Dependencies** — `ext-tokenizer`, `goaop/parser-reflection` ^4.0.1 || ^5.0.1, `goaop/dissect` ^4.0, `psr/container` ^1.1 || ^2.0 and `symfony/finder` ^7.4 || ^8.0. `nikic/php-parser` 5 comes through parser-reflection and is no longer required directly. `symfony/console` ^7.4 || ^8.0 is needed for the `bin/aspect` tool. **Added** * [Feature] **First-class callable advices** — the main way advices are now wired into woven code. Generated proxies declare each aspect-method advice as `Interceptor::before(The::aspect(MonitorAspect::class)->beforeMethodExecution(...))` — an eager first-class callable, since the interceptor list is only built while the intercepted method or hook is already executing (`LazyAdvisorAccessor` is removed). Compiled advisor cache files use the same facade in its lazy static-data form, e.g. `Interceptor::before(MonitorAspect::class, 'beforeMethodExecution')`, which returns a native PHP lazy proxy: interceptor construction, aspect resolution from the container and advice callable creation are all deferred until the advice is actually used, so cached advices whose pointcut never matches never instantiate their aspect. Advices registered in the container as plain closures (not aspect methods) are resolved through the new `The::advice('advisorId')` accessor, which unwraps `Advisor` and interceptor values down to the raw advice closure. @@ -18,6 +18,7 @@ See [UPGRADE-4.0.md](UPGRADE-4.0.md) for the migration guide. * [Feature] **Generic types for static analysis** — the core joinpoint interfaces (`Joinpoint`, `Invocation`, `MethodInvocation`, `ConstructorInvocation`, `FieldAccess`, `FunctionInvocation`, ...) declare `@template` types for the target class and the return value, so PHPStan and IDEs know the types of `getThis()`, `proceed()` and friends inside advices. * [Feature] **Attributes are propagated to proxies** — attributes of the woven class, its methods, parameters, constants and properties are copied from the source AST (arguments included, not evaluated values), so runtime attribute inspection of a proxy returns the original attributes (e.g. `#[\Deprecated]`). * [Feature] [BC BREAK] **Interface-keyed registration listeners** — new `AspectContainer::onRegistration(string $interfaceFQCN, Closure $listener)`: the listener receives the id (class-name) and the container whenever a deferred service whose id implements the interface is registered via `addLazyService()`; matching never autoloads, so an id whose class is not loaded yet reaches the listener as a candidate. Values are never touched, so laziness is preserved; with no listeners armed (production) registration stays a pure array write. The kernel arms one `Aspect::class` listener in debug mode to track aspect source files as freshness resources at registration time, resolving them through composer without loading the aspects (previously hardwired into `registerAspect()`). `AspectContainer::addResource()` is now part of the interface and `final public` on `Container` (was `final protected`). +* [Feature] [BC BREAK] **PSR-11 container** — `AspectContainer` extends `Psr\Container\ContainerInterface`: `getValue()` is renamed to `get()`, `has()` is inherited from the PSR interface. `ServiceNotFoundException` implements `NotFoundExceptionInterface`, and a container entry of the wrong type in `getService()` throws the new `Go\Aop\Exception\ContainerException` (an `InvalidConfigurationException` implementing `ContainerExceptionInterface`). * [Feature] Console commands declare their name, description and help with `#[AsCommand]`, `bin/aspect` loads them lazily, and every command returns `Command::SUCCESS`/`Command::FAILURE` (`debug:weaving` used to return the raw error count). `cache:warmup:aop` stops cleanly on SIGINT/SIGTERM. * [Feature] [BC BREAK] **Doctrine bridge rewritten for the trait-based engine (#671)** — new `Go\Bridge\Doctrine\WovenEntityClassLocator` lets Doctrine mapping drivers (ORM 3.6+) discover entities through the autoloader, so the woven class is mapped instead of the original source, and entities loaded before discovery are no longer missed. `MetadataLoadInterceptor` no longer rewrites metadata (the old mapped-superclass workaround only matched the extend-based engine); it now reports woven entities with intercepted mapped properties when Doctrine native lazy objects are disabled. It is registered as a plain listener: `$eventManager->addEventListener(Events::loadClassMetadata, new MetadataLoadInterceptor())` (it is no longer an `EventSubscriber`). diff --git a/UPGRADE-4.0.md b/UPGRADE-4.0.md index bcf47ccd..be056f29 100644 --- a/UPGRADE-4.0.md +++ b/UPGRADE-4.0.md @@ -20,7 +20,8 @@ Requirements ------------ - PHP 8.4+ with `ext-tokenizer`. -- `goaop/parser-reflection` ^4.0.1 || ^5.0.1, `goaop/dissect` ^4.0, `symfony/finder` ^7.4 || ^8.0. +- `goaop/parser-reflection` ^4.0.1 || ^5.0.1, `goaop/dissect` ^4.0, `symfony/finder` ^7.4 || ^8.0, + `psr/container` ^1.1 || ^2.0. `nikic/php-parser` 5 is installed through parser-reflection. - `laminas/laminas-code` is no longer used: proxies are generated by the internal `Go\Proxy\Generator` package. Code that extended the framework generators or relied on laminas @@ -113,6 +114,24 @@ object of the wrong type now fails on first use, not at registration. `AspectContainer::addResource()` is part of the interface. `onRegistration()` lets you react to deferred registrations of services implementing an interface. +### PSR-11 container + +`AspectContainer` extends `Psr\Container\ContainerInterface`, so the container can be passed to any code +that expects a PSR-11 container. `getValue()` is renamed to `get()`, and `has()` comes from the PSR +interface with the same signature. A custom `containerClass` implements `get()` instead of `getValue()`. + +```php +// before +$pointcut = $container->getValue('my.pointcut'); + +// 4.0 +$pointcut = $container->get('my.pointcut'); +``` + +An unknown id throws `ServiceNotFoundException`, which implements `Psr\Container\NotFoundExceptionInterface`. +`getService()` throws `Go\Aop\Exception\ContainerException` (an `InvalidConfigurationException` implementing +`Psr\Container\ContainerExceptionInterface`) when the entry is not an instance of the requested class. + ### Advice and interceptor API - The `AdviceBefore`, `AdviceAfter` and `AdviceAround` marker interfaces are removed. `Advice` @@ -206,6 +225,7 @@ Renamed enums and constants | `TransformerResultEnum::RESULT_ABORTED` | `TransformerResult::Aborted` | | `FieldAccessType::READ` / `WRITE` | `FieldAccessType::Read` / `Write` | | `AspectContainer::AOP_PROXIED_SUFFIX` | `AspectContainer::ORIGINAL_TRAIT_SUFFIX` | +| `AspectContainer::getValue()` | `AspectContainer::get()` (PSR-11) | The `Go\Proxy\Generator` enums (`Visibility`, `PropertyModifier`, `ClassModifier`) use PascalCase cases as well. Custom source transformers return `TransformerResult` cases, and advices comparing `getAccessType()` use the new @@ -258,7 +278,8 @@ any Go! AOP failure. Several throw sites changed their type: |------------------------------------------------------------------|--------------------------------------------------------|-----| | Invalid kernel options, container registrations, console input | `RuntimeException`, `InvalidArgumentException`, `UnexpectedValueException` | `InvalidConfigurationException` (an `InvalidArgumentException`) | | Invalid pointcut expression | `UnexpectedValueException`, `InvalidArgumentException` | `PointcutSyntaxException` (an `AspectException`) | -| Unknown container id | `OutOfBoundsException` | `ServiceNotFoundException` (an `OutOfBoundsException`) | +| Unknown container id | `OutOfBoundsException` | `ServiceNotFoundException` (an `OutOfBoundsException` and a PSR-11 `NotFoundExceptionInterface`) | +| Container entry of the wrong type in `getService()` | `InvalidArgumentException` | `ContainerException` (an `InvalidConfigurationException` and a PSR-11 `ContainerExceptionInterface`) | | Weaving, proxy generation, cache writes | `RuntimeException`, `LogicException`, `InvalidArgumentException` | `WeavingException` (a `RuntimeException`) | | Advisor that can not be cached | `Go\Core\Cache\NotCompilableException` | `Go\Aop\Exception\NotCompilableException` | diff --git a/composer.json b/composer.json index 1f3a511b..1644d0ec 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "ext-tokenizer": "*", "goaop/dissect": "^4.0", "goaop/parser-reflection": "^4.0.1 || ^5.0.1", + "psr/container": "^1.1 || ^2.0", "symfony/finder": "^7.4 || ^8.0" }, "require-dev": { diff --git a/docs/configuration.md b/docs/configuration.md index 59f54ead..a21b531d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,10 +22,10 @@ bit throws `Go\Aop\Exception\InvalidConfigurationException`. | `appDir` | `string` | the directory that contains `vendor/` | Application root. Cache paths mirror source paths relative to it, and only files below it are woven. | | `debug` | `bool` | `false` | Development mode: changes of the kernel file and of aspect files invalidate the cache, and every class and `include` is loaded through the weaving stream filter. With `false`, woven classes are loaded from the cache through Composer's class map. Use `false` in production. | | `includePaths` | `string[]` | `[]` (everything below `appDir`) | Directories that are woven. Keep it narrow: weaving vendor code is slow and rarely needed. | -| `excludePaths` | `string[]` | `[]` | Directories that are never woven. The cache directory, the framework itself and its runtime dependencies (goaop/dissect, goaop/parser-reflection, nikic/php-parser, symfony/finder) are always excluded. | +| `excludePaths` | `string[]` | `[]` | Directories that are never woven. The cache directory, the framework itself and its runtime dependencies (goaop/dissect, goaop/parser-reflection, nikic/php-parser, psr/container, symfony/finder) are always excluded. | | `features` | `int` | `0` | Bitmask of `Go\Aop\Features` flags, see below. | | `cacheFileMode` | `int` | `0770` minus the umask | Permissions of cache files. Must be between `0600` and `0777` and give the owner read and write access; directories get matching search bits. | -| `containerClass` | `class-string` | `Go\Core\Container` | Aspect container implementation, must implement `Go\Core\AspectContainer`. | +| `containerClass` | `class-string` | `Go\Core\Container` | Aspect container implementation, must implement `Go\Core\AspectContainer` (a PSR-11 `ContainerInterface`). | ## Features diff --git a/src/Aop/Exception/ContainerException.php b/src/Aop/Exception/ContainerException.php new file mode 100644 index 00000000..fd6d2ddd --- /dev/null +++ b/src/Aop/Exception/ContainerException.php @@ -0,0 +1,20 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Aop\Exception; + +use Psr\Container\ContainerExceptionInterface; + +/** + * A container entry that is registered, but can not be retrieved as declared + */ +final class ContainerException extends InvalidConfigurationException implements ContainerExceptionInterface {} diff --git a/src/Aop/Exception/ServiceNotFoundException.php b/src/Aop/Exception/ServiceNotFoundException.php index 36ccfd3f..27016ef5 100644 --- a/src/Aop/Exception/ServiceNotFoundException.php +++ b/src/Aop/Exception/ServiceNotFoundException.php @@ -13,8 +13,9 @@ namespace Go\Aop\Exception; use OutOfBoundsException; +use Psr\Container\NotFoundExceptionInterface; /** * A service or value that is not registered in the aspect container */ -class ServiceNotFoundException extends OutOfBoundsException implements ExceptionInterface {} +class ServiceNotFoundException extends OutOfBoundsException implements ExceptionInterface, NotFoundExceptionInterface {} diff --git a/src/Aop/Framework/The.php b/src/Aop/Framework/The.php index 1f8bc248..3d8cf991 100644 --- a/src/Aop/Framework/The.php +++ b/src/Aop/Framework/The.php @@ -45,7 +45,7 @@ public static function aspect(string $aspectClass): Aspect public static function advice(string $advisorId): Closure { - $value = self::getContainer()->getValue($advisorId); + $value = self::getContainer()->get($advisorId); if ($value instanceof Advisor) { $value = $value->getAdvice(); diff --git a/src/Aop/Pointcut/PointcutReference.php b/src/Aop/Pointcut/PointcutReference.php index e16dcc3a..60808b87 100644 --- a/src/Aop/Pointcut/PointcutReference.php +++ b/src/Aop/Pointcut/PointcutReference.php @@ -75,7 +75,7 @@ public function compileToPhp(): Expr private function getPointcut(): Pointcut { if (!isset($this->pointcut)) { - $pointcutValue = $this->container->getValue($this->pointcutId); + $pointcutValue = $this->container->get($this->pointcutId); if (!$pointcutValue instanceof Pointcut) { throw new AspectException("Pointcut reference {$this->pointcutId} resolves to " . get_debug_type($pointcutValue) . ', not to a pointcut'); } diff --git a/src/Core/AGENTS.md b/src/Core/AGENTS.md index f5d3e3d7..7440801f 100644 --- a/src/Core/AGENTS.md +++ b/src/Core/AGENTS.md @@ -1,12 +1,12 @@ # src/Core — Container and aspect loading ## Container (Container.php) -- Generic, AOP-unaware DI container: add(by class-string|key), getService(), getValue(), addLazyService(Closure), onRegistration(interfaceFQCN, listener), addResource() +- Generic, AOP-unaware DI container, PSR-11 (AspectContainer extends Psr\Container\ContainerInterface): add(by class-string|key), get(), has(), getService(), addLazyService(Closure), onRegistration(interfaceFQCN, listener), addResource() - Automatic tagging by interface; deferred services materialize as native lazy proxies via NativeLazyProxy (engine probe, no userland compatibility predicate) - add() runs on every request: tags via class_implements(), the class file of an added object becomes a resource only on the next isFreshSince() (no reflection or stat calls at registration) - onRegistration() never autoloads: ids of loaded classes are matched exactly, ids not loaded yet reach the listener as candidates - Aspects are typical services — no registerAspect(); the kernel arms a debug-only Aspect::class onRegistration listener for resource tracking (files resolved via AopComposerLoader::findOriginalFile(), aspects stay unloaded) and registers framework services via FrameworkServices::register(); CachePathManager is added eagerly by the kernel (the class loader reads its cache index right away) -- Throws SPL exceptions only (InvalidArgumentException, UnexpectedValueException, OutOfBoundsException) +- Throws framework exceptions: ServiceNotFoundException (unknown id, PSR NotFoundExceptionInterface), ContainerException (entry of the wrong type, PSR ContainerExceptionInterface), InvalidConfigurationException (invalid registration) ## Aspect loading - AspectLoader — scans aspect classes for pointcut/advice attributes → Advisor[]; CachedAspectLoader decorates it (both implement AspectLoaderInterface) diff --git a/src/Core/AspectContainer.php b/src/Core/AspectContainer.php index 68fc4a66..946729af 100644 --- a/src/Core/AspectContainer.php +++ b/src/Core/AspectContainer.php @@ -13,12 +13,14 @@ namespace Go\Core; use Closure; -use OutOfBoundsException; +use Go\Aop\Exception\ContainerException; +use Go\Aop\Exception\ServiceNotFoundException; +use Psr\Container\ContainerInterface; /** - * Aspect container interface + * Aspect container interface, a PSR-11 container with the registration API of the framework */ -interface AspectContainer +interface AspectContainer extends ContainerInterface { /** * Prefix for function interceptor @@ -81,7 +83,8 @@ interface AspectContainer * * @template T of object * - * @throws OutOfBoundsException if service was not found + * @throws ServiceNotFoundException if service was not found + * @throws ContainerException if the entry is not an instance of the given class */ public function getService(string $className): object; @@ -98,19 +101,14 @@ public function getServicesByInterface(string $interfaceTagClassName): array; /** * Returns a value from the container * - * @param string $key Given key + * @param string $id Identifier of the entry * - * @return ($key is class-string ? T : mixed) - * @throws OutOfBoundsException if key was not found + * @return ($id is class-string ? T : mixed) + * @throws ServiceNotFoundException if no entry was found for the identifier * * @template T of object */ - public function getValue(string $key): mixed; - - /** - * Checks if item with specified id is present in the container - */ - public function has(string $id): bool; + public function get(string $id): mixed; /** * Registers a listener that is called whenever a deferred service whose id may be a diff --git a/src/Core/AspectKernel.php b/src/Core/AspectKernel.php index 42575612..83c114ec 100644 --- a/src/Core/AspectKernel.php +++ b/src/Core/AspectKernel.php @@ -61,6 +61,7 @@ abstract class AspectKernel 'goaop/dissect', 'goaop/parser-reflection', 'nikic/php-parser', + 'psr/container', 'symfony/finder', ]; diff --git a/src/Core/Container.php b/src/Core/Container.php index 3f6cf676..c8fc3d71 100644 --- a/src/Core/Container.php +++ b/src/Core/Container.php @@ -13,12 +13,13 @@ namespace Go\Core; use Closure; +use Go\Aop\Exception\ContainerException; use Go\Aop\Exception\InvalidConfigurationException; use Go\Aop\Exception\ServiceNotFoundException; use ReflectionClass; /** - * DI-container + * DI-container, PSR-11 compatible */ final class Container implements AspectContainer { @@ -132,26 +133,26 @@ public function addLazyService(string $id, Closure $lazyInitializationClosure): public function getService(string $className): object { - $service = $this->getValue($className); + $service = $this->get($className); if (!$service instanceof $className) { - throw new InvalidConfigurationException("Service $className is not properly registered"); + throw new ContainerException("Service $className is not properly registered"); } return $service; } - public function getValue(string $key): mixed + public function get(string $id): mixed { // isset() first for speed, array_key_exists() keeps a stored null value retrievable - if (!isset($this->values[$key]) && !array_key_exists($key, $this->values)) { - if (isset($this->factories[$key])) { - $this->materializeService($key); + if (!isset($this->values[$id]) && !array_key_exists($id, $this->values)) { + if (isset($this->factories[$id])) { + $this->materializeService($id); } else { - throw new ServiceNotFoundException("Value $key is not defined in the container"); + throw new ServiceNotFoundException("Value $id is not defined in the container"); } } - return $this->values[$key]; + return $this->values[$id]; } public function has(string $id): bool @@ -191,7 +192,7 @@ public function getServicesByInterface(string $interfaceTagClassName): array $version = $this->registrationVersion; $values = []; foreach (($this->tags[$interfaceTagClassName] ?? []) as $containerKey) { - $values[$containerKey] = $this->getValue($containerKey); + $values[$containerKey] = $this->get($containerKey); } // A re-entrant registration while collecting the values invalidates this result if ($isConclusive && $version === $this->registrationVersion) { @@ -239,7 +240,7 @@ private function createLazyService(string $id, Closure $factory): object return NativeLazyProxy::tryCreate($id, function () use ($id, $factory): object { $instance = $factory($this); if (!$instance instanceof $id) { - throw new InvalidConfigurationException("Service $id is not properly registered"); + throw new ContainerException("Service $id is not properly registered"); } return $instance; diff --git a/tests/Aop/Exception/ExceptionHierarchyTest.php b/tests/Aop/Exception/ExceptionHierarchyTest.php index 1d9f0aee..b16a9456 100644 --- a/tests/Aop/Exception/ExceptionHierarchyTest.php +++ b/tests/Aop/Exception/ExceptionHierarchyTest.php @@ -32,6 +32,7 @@ public static function exceptionTypes(): array 'invalid config' => [InvalidConfigurationException::class, InvalidArgumentException::class], 'pointcut syntax' => [PointcutSyntaxException::class, AspectException::class], 'service not found' => [ServiceNotFoundException::class, OutOfBoundsException::class], + 'container' => [ContainerException::class, InvalidConfigurationException::class], 'weaving' => [WeavingException::class, RuntimeException::class], 'not compilable' => [NotCompilableException::class, AspectException::class], ]; @@ -55,7 +56,7 @@ public function testExceptionTypeImplementsMarkerAndKeepsSplParent(string $excep public function testEveryThrowInSourcesUsesAFrameworkException(): void { $allowed = ['AspectException', 'InvalidConfigurationException', 'PointcutSyntaxException', - 'ServiceNotFoundException', 'WeavingException', 'NotCompilableException', + 'ServiceNotFoundException', 'ContainerException', 'WeavingException', 'NotCompilableException', // Converts warnings inside the cache warmer loop, which catches it right away 'ErrorException']; $offenders = []; diff --git a/tests/Core/AspectLoaderTest.php b/tests/Core/AspectLoaderTest.php index 07b118d0..cbf26ce7 100644 --- a/tests/Core/AspectLoaderTest.php +++ b/tests/Core/AspectLoaderTest.php @@ -86,7 +86,7 @@ public function testLoadAndRegisterAddsLoadedItemsToContainer(): void $loader = new AspectLoader($container, $extension); $loader->loadAndRegister(new AspectLoaderTestAspectOne()); - $this->assertSame($advisor, $container->getValue('aspect.advisor')); + $this->assertSame($advisor, $container->get('aspect.advisor')); } public function testGetUnloadedAspectsReturnsOnlyAspectsNotYetLoaded(): void diff --git a/tests/Core/ContainerTest.php b/tests/Core/ContainerTest.php index 208879de..ca0a2b5c 100644 --- a/tests/Core/ContainerTest.php +++ b/tests/Core/ContainerTest.php @@ -25,6 +25,9 @@ use Go\Tests\TestProject\Aspect\DoSomethingAspect; use Go\Tests\TestProject\Aspect\EnumMethodAspect; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; use stdClass; #[\PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations] @@ -86,7 +89,7 @@ public function testPointcutCanBeRegisteredAndReceived(): void $pointcut = $this->createMock(Pointcut::class); $this->container->add('test', $pointcut); - $this->assertSame($pointcut, $this->container->getValue('test')); + $this->assertSame($pointcut, $this->container->get('test')); // Verify that tag is working $pointcuts = $this->container->getServicesByInterface(Pointcut::class); $this->assertSame(['test' => $pointcut], $pointcuts); @@ -123,7 +126,7 @@ public function testAdvisorCanBeRegistered(): void $advisor = $this->createMock(Advisor::class); $this->container->add('test', $advisor); - $this->assertSame($advisor, $this->container->getValue('test')); + $this->assertSame($advisor, $this->container->get('test')); // Verify that tag is working $advisors = $this->container->getServicesByInterface(Advisor::class); @@ -173,7 +176,7 @@ public function testNullValueIsStoredAndRetrievable(): void $this->container->add('nothing', null); $this->assertTrue($this->container->has('nothing')); - $this->assertNull($this->container->getValue('nothing')); + $this->assertNull($this->container->get('nothing')); } public function testHasMethod(): void @@ -193,11 +196,45 @@ public function testGetServiceThrowsOutOfBoundsExceptionOnUnknown(): void $this->container->getService(stdClass::class); } - public function testGetValueThrowsOutOfBoundsExceptionOnUnknown(): void + public function testGetThrowsOutOfBoundsExceptionOnUnknown(): void { $this->expectException(\OutOfBoundsException::class); $this->expectExceptionMessageMatches('/Value some.key is not defined/'); - $this->container->getValue('some.key'); + $this->container->get('some.key'); + } + + public function testContainerIsPsrContainer(): void + { + $this->assertContains(ContainerInterface::class, class_implements(Container::class)); + } + + public function testGetThrowsPsrNotFoundExceptionOnUnknown(): void + { + $this->expectException(NotFoundExceptionInterface::class); + $this->container->get('some.key'); + } + + public function testHasGuaranteesThatGetDoesNotThrowNotFound(): void + { + $this->container->add('nothing', null); + $this->container->add('eager', new stdClass()); + $this->container->addLazyService(StatefulTestAspect::class, fn(): StatefulTestAspect => new StatefulTestAspect(1)); + + $this->assertTrue($this->container->has('nothing')); + $this->assertNull($this->container->get('nothing')); + $this->assertTrue($this->container->has('eager')); + $this->assertInstanceOf(stdClass::class, $this->container->get('eager')); + $this->assertTrue($this->container->has(StatefulTestAspect::class)); + $this->assertSame(1, $this->container->get(StatefulTestAspect::class)->getState()); + $this->assertFalse($this->container->has('some.key')); + } + + public function testGetServiceThrowsPsrContainerExceptionOnTypeMismatch(): void + { + $this->expectException(ContainerExceptionInterface::class); + + $this->container->add(First::class, new stdClass()); + $this->container->getService(First::class); } public function testGetServiceEnsuresThatKeyAndReturnedTypeMatches(): void