diff --git a/docs/book/exceptions.md b/docs/book/exceptions.md index 7f6bae3b..97c93357 100644 --- a/docs/book/exceptions.md +++ b/docs/book/exceptions.md @@ -64,6 +64,14 @@ a named constructor cannot: Everything else is a named constructor on the component's SPL-shaped class. +### The one exception to the rule + +`PhpDb\Container\AdapterInterfaceDelegator` renders a template at the throw site rather than +calling a named constructor. The ServiceManager decides the type it must throw, and Laminas +annotates `ServiceNotFoundException` `@final`, so this package cannot own the class. It owns +the wording instead, as `PhpDb\Exception\ContainerException::MISSING_SERVICE`. Anywhere the +thrown type is ours, the rule above applies without exception. + ## Component namespaces There is exactly one marker interface, `PhpDb\Exception\ExceptionInterface`, and every diff --git a/src/Container/AbstractAdapterInterfaceFactory.php b/src/Container/AbstractAdapterInterfaceFactory.php index 64018557..b596b025 100644 --- a/src/Container/AbstractAdapterInterfaceFactory.php +++ b/src/Container/AbstractAdapterInterfaceFactory.php @@ -88,11 +88,7 @@ public function __invoke( $driverClass = $this->config[$requestedName]['driver'] ?? null; if (null === $driverClass) { - throw ContainerException::forService( - $requestedName, - self::class, - 'no driver configured', - ); + throw ContainerException::forMissingDriver($requestedName, self::class); } /** @var DriverInterface|PdoDriverInterface $driver */ diff --git a/src/Container/AdapterInterfaceDelegator.php b/src/Container/AdapterInterfaceDelegator.php index 64b8384a..8213006e 100644 --- a/src/Container/AdapterInterfaceDelegator.php +++ b/src/Container/AdapterInterfaceDelegator.php @@ -7,7 +7,7 @@ use Laminas\ServiceManager\Exception\ServiceNotFoundException; use PhpDb\Adapter\AdapterAwareInterface; use PhpDb\Adapter\AdapterInterface; -use PhpDb\Exception; +use PhpDb\Exception\ContainerException; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; @@ -33,18 +33,11 @@ public function __invoke( $instance = $callback(); if (! $instance instanceof AdapterAwareInterface) { - throw new Exception\RuntimeException(sprintf( - 'Delegated service "%s" must implement %s', - $name, - AdapterAwareInterface::class, - )); + throw ContainerException::forNonAdapterAwareDelegate($name, AdapterAwareInterface::class); } if (! $container->has($this->adapterName)) { - throw new ServiceNotFoundException(sprintf( - 'Service "%s" not found in container', - $this->adapterName, - )); + throw new ServiceNotFoundException(sprintf(ContainerException::MISSING_SERVICE, $this->adapterName)); } $databaseAdapter = $container->get($this->adapterName); diff --git a/src/Container/AdapterInterfaceFactory.php b/src/Container/AdapterInterfaceFactory.php index fe6db760..b4777cea 100644 --- a/src/Container/AdapterInterfaceFactory.php +++ b/src/Container/AdapterInterfaceFactory.php @@ -23,22 +23,14 @@ public function __invoke( string $requestedName, ): AdapterInterface&Adapter { if (! $container->has('config')) { - throw ContainerException::forService( - $requestedName, - self::class, - 'Container is missing a config service', - ); + throw ContainerException::forMissingConfigService($requestedName, self::class); } $config = $container->get('config') ?? []; $adapterConfig = $config[AdapterInterface::class] ?? $config[Adapter::class] ?? []; if ([] === $adapterConfig) { - throw ContainerException::forService( - AdapterInterface::class, - self::class, - "No configuration found for {$requestedName}", - ); + throw ContainerException::forMissingConfiguration(AdapterInterface::class, self::class, $requestedName); } /** @var class-string|class-string|null $driverClass */ diff --git a/src/Exception/ContainerException.php b/src/Exception/ContainerException.php index 20d159ff..d6e7f2b0 100644 --- a/src/Exception/ContainerException.php +++ b/src/Exception/ContainerException.php @@ -10,8 +10,46 @@ final class ContainerException extends RuntimeException implements ContainerExceptionInterface { + /** + * The ServiceManager dictates the thrown type here, and Laminas annotates + * ServiceNotFoundException @final, so this package owns the wording without owning the + * class. + */ + final public const string MISSING_SERVICE = 'Service "%s" not found in container'; + + final public const string MISSING_CONFIGURATION = 'No configuration found for %s'; + + final public const string MISSING_CONFIG_SERVICE = 'Container is missing a config service'; + + final public const string MISSING_DRIVER = 'no driver configured'; + + final public const string NON_ADAPTER_AWARE_DELEGATE = 'Delegated service "%s" must implement %s'; + final public const string SERVICE = 'Failed to create service "%s" in factory %s Reason: %s'; + public static function forMissingConfigService(string $serviceName, string $factoryClass): self + { + return self::forService($serviceName, $factoryClass, self::MISSING_CONFIG_SERVICE); + } + + public static function forMissingConfiguration( + string $serviceName, + string $factoryClass, + string $requestedName, + ): self { + return self::forService($serviceName, $factoryClass, sprintf(self::MISSING_CONFIGURATION, $requestedName)); + } + + public static function forMissingDriver(string $serviceName, string $factoryClass): self + { + return self::forService($serviceName, $factoryClass, self::MISSING_DRIVER); + } + + public static function forNonAdapterAwareDelegate(string $name, string $interface): self + { + return new self(sprintf(self::NON_ADAPTER_AWARE_DELEGATE, $name, $interface)); + } + public static function forService( string $serviceName, string $factoryClass, diff --git a/src/ResultSet/AbstractResultSet.php b/src/ResultSet/AbstractResultSet.php index ca236318..b4ddc698 100644 --- a/src/ResultSet/AbstractResultSet.php +++ b/src/ResultSet/AbstractResultSet.php @@ -45,7 +45,7 @@ abstract class AbstractResultSet implements ResultSetInterface public function buffer(): ResultSetInterface { if ($this->buffer === -2) { - throw new RuntimeException('Buffering must be enabled before iteration is started'); + throw RuntimeException::forUnbufferedIteration(); } elseif ($this->buffer === null) { $this->buffer = []; if ($this->dataSource instanceof ResultInterface) { diff --git a/src/ResultSet/Exception/RuntimeException.php b/src/ResultSet/Exception/RuntimeException.php index 379fe2ca..4b972d66 100644 --- a/src/ResultSet/Exception/RuntimeException.php +++ b/src/ResultSet/Exception/RuntimeException.php @@ -6,4 +6,12 @@ use PhpDb\Exception; -class RuntimeException extends Exception\RuntimeException {} +class RuntimeException extends Exception\RuntimeException +{ + final public const string UNBUFFERED_ITERATION = 'Buffering must be enabled before iteration is started'; + + public static function forUnbufferedIteration(): self + { + return new self(self::UNBUFFERED_ITERATION); + } +} diff --git a/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php b/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php index bc55857f..e8de58e1 100644 --- a/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php +++ b/test/unit/Adapter/Container/AbstractAdapterInterfaceFactoryTest.php @@ -93,7 +93,7 @@ public function testGetConfigReturnsEmptyWhenContainerHasNoConfig(): void #[DataProvider('providerInvalidService')] public function testInvalidService(string $service): void { - $this->expectException(ServiceNotFoundException::class); + self::expectException(ServiceNotFoundException::class); $this->serviceManager->get($service); } @@ -111,8 +111,8 @@ public function testInvokeThrowsWhenDriverNotConfigured(): void $factory = new AbstractAdapterInterfaceFactory(); $factory->canCreate($container, 'PhpDb\Adapter\NoDriver'); - $this->expectException(ContainerException::class); - $this->expectExceptionMessage('no driver configured'); + self::expectException(ContainerException::class); + self::expectExceptionMessage(ContainerException::MISSING_DRIVER); $factory($container, 'PhpDb\Adapter\NoDriver'); } diff --git a/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php b/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php index 27d727d9..c263b875 100644 --- a/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php +++ b/test/unit/Adapter/Container/AdapterInterfaceDelegatorTest.php @@ -8,10 +8,12 @@ use Laminas\ServiceManager\Exception\ServiceNotFoundException; use Laminas\ServiceManager\ServiceManager; use PhpDb\Adapter\Adapter; +use PhpDb\Adapter\AdapterAwareInterface; use PhpDb\Adapter\AdapterInterface; use PhpDb\Adapter\Driver\DriverInterface; use PhpDb\Adapter\Platform\PlatformInterface; use PhpDb\Container\AdapterInterfaceDelegator; +use PhpDb\Exception\ContainerException; use PhpDb\Exception\RuntimeException; use PhpDb\ResultSet\ResultSetInterface; use PhpDbTest\Adapter\TestAsset\ConcreteAdapterAwareObject; @@ -24,6 +26,8 @@ use Psr\Container\NotFoundExceptionInterface; use stdClass; +use function sprintf; + #[Group('unit')] #[CoversMethod(AdapterInterfaceDelegator::class, '__construct')] #[CoversMethod(AdapterInterfaceDelegator::class, '__set_state')] @@ -247,8 +251,8 @@ public function testSetAdapterShouldNotBeCalledForMissingAdapter(): void $callback = static fn(): ConcreteAdapterAwareObject => new ConcreteAdapterAwareObject(); - $this->expectException(ServiceNotFoundException::class); - $this->expectExceptionMessage('Service "PhpDb\Adapter\AdapterInterface" not found in container'); + self::expectException(ServiceNotFoundException::class); + self::expectExceptionMessage(sprintf(ContainerException::MISSING_SERVICE, AdapterInterface::class)); (new AdapterInterfaceDelegator())( $container, @@ -268,10 +272,12 @@ public function testSetAdapterShouldNotBeCalledForWrongClassInstance(): void $callback = static fn(): stdClass => new stdClass(); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'Delegated service "stdClass" must implement PhpDb\Adapter\AdapterAwareInterface', - ); + self::expectException(RuntimeException::class); + self::expectExceptionMessage(sprintf( + ContainerException::NON_ADAPTER_AWARE_DELEGATE, + stdClass::class, + AdapterAwareInterface::class, + )); (new AdapterInterfaceDelegator())( $container, diff --git a/test/unit/Container/AdapterInterfaceFactoryTest.php b/test/unit/Container/AdapterInterfaceFactoryTest.php index cbdf7b08..29dc54c5 100644 --- a/test/unit/Container/AdapterInterfaceFactoryTest.php +++ b/test/unit/Container/AdapterInterfaceFactoryTest.php @@ -111,8 +111,8 @@ public function testInvokeThrowsWhenAdapterConfigIsEmpty(): void $factory = new AdapterInterfaceFactory(); - $this->expectException(ContainerException::class); - $this->expectExceptionMessage('No configuration found for'); + self::expectException(ContainerException::class); + self::expectExceptionMessage('No configuration found for'); $factory($container, AdapterInterface::class); } @@ -130,8 +130,8 @@ public function testInvokeThrowsWhenContainerHasNoConfig(): void $factory = new AdapterInterfaceFactory(); - $this->expectException(ContainerException::class); - $this->expectExceptionMessage('Container is missing a config service'); + self::expectException(ContainerException::class); + self::expectExceptionMessage(ContainerException::MISSING_CONFIG_SERVICE); $factory($container, AdapterInterface::class); } } diff --git a/test/unit/Exception/ContainerExceptionTest.php b/test/unit/Exception/ContainerExceptionTest.php index d14a42ff..b3481394 100644 --- a/test/unit/Exception/ContainerExceptionTest.php +++ b/test/unit/Exception/ContainerExceptionTest.php @@ -15,9 +15,59 @@ use function sprintf; #[Group('unit')] +#[CoversMethod(ContainerException::class, 'forMissingConfigService')] +#[CoversMethod(ContainerException::class, 'forMissingConfiguration')] +#[CoversMethod(ContainerException::class, 'forMissingDriver')] +#[CoversMethod(ContainerException::class, 'forNonAdapterAwareDelegate')] #[CoversMethod(ContainerException::class, 'forService')] final class ContainerExceptionTest extends TestCase { + #[Test] + public function forMissingConfigServiceComposesTheReasonIntoTheServiceTemplate(): void + { + self::assertSame( + sprintf( + ContainerException::SERVICE, + 'Svc', + 'Factory', + ContainerException::MISSING_CONFIG_SERVICE, + ), + ContainerException::forMissingConfigService('Svc', 'Factory')->getMessage(), + ); + } + + #[Test] + public function forMissingConfigurationNamesTheRequestedService(): void + { + self::assertSame( + sprintf( + ContainerException::SERVICE, + 'Svc', + 'Factory', + sprintf(ContainerException::MISSING_CONFIGURATION, 'Requested'), + ), + ContainerException::forMissingConfiguration('Svc', 'Factory', 'Requested')->getMessage(), + ); + } + + #[Test] + public function forMissingDriverComposesTheReasonIntoTheServiceTemplate(): void + { + self::assertSame( + sprintf(ContainerException::SERVICE, 'Svc', 'Factory', ContainerException::MISSING_DRIVER), + ContainerException::forMissingDriver('Svc', 'Factory')->getMessage(), + ); + } + + #[Test] + public function forNonAdapterAwareDelegateRendersItsTemplate(): void + { + self::assertSame( + sprintf(ContainerException::NON_ADAPTER_AWARE_DELEGATE, 'Svc', 'SomeInterface'), + ContainerException::forNonAdapterAwareDelegate('Svc', 'SomeInterface')->getMessage(), + ); + } + #[Test] public function forServiceIsAPsrContainerException(): void { diff --git a/test/unit/ResultSet/AbstractResultSetTest.php b/test/unit/ResultSet/AbstractResultSetTest.php index fc1401b7..0f02c102 100644 --- a/test/unit/ResultSet/AbstractResultSetTest.php +++ b/test/unit/ResultSet/AbstractResultSetTest.php @@ -55,8 +55,8 @@ public function testBuffer(): void ])); $resultSet->next(); // start iterator // Verify buffer() throws exception when called after iteration starts - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Buffering must be enabled before iteration is started'); + self::expectException(RuntimeException::class); + self::expectExceptionMessage(RuntimeException::UNBUFFERED_ITERATION); $resultSet->buffer(); } @@ -248,7 +248,7 @@ public function testInitialize(): void ])); // Verify invalid data type throws exception - $this->expectException(TypeError::class); + self::expectException(TypeError::class); /** @noinspection ALL */ $resultSet->initialize('foo'); } diff --git a/test/unit/ResultSet/Exception/RuntimeExceptionTest.php b/test/unit/ResultSet/Exception/RuntimeExceptionTest.php new file mode 100644 index 00000000..bab9cbdb --- /dev/null +++ b/test/unit/ResultSet/Exception/RuntimeExceptionTest.php @@ -0,0 +1,32 @@ +getMessage(), + ); + } + + #[Test] + public function forUnbufferedIterationReturnsTheComponentExceptionType(): void + { + self::assertInstanceOf(ExceptionInterface::class, RuntimeException::forUnbufferedIteration()); + } +} diff --git a/test/unit/ResultSet/ResultSetIntegrationTest.php b/test/unit/ResultSet/ResultSetIntegrationTest.php index bbe05321..f25fd173 100644 --- a/test/unit/ResultSet/ResultSetIntegrationTest.php +++ b/test/unit/ResultSet/ResultSetIntegrationTest.php @@ -77,8 +77,8 @@ public function testBufferCalledAfterIterationThrowsException(): void $this->resultSet->current(); // Verify buffer() throws exception when called after iteration has started - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Buffering must be enabled before iteration is started'); + self::expectException(RuntimeException::class); + self::expectExceptionMessage(RuntimeException::UNBUFFERED_ITERATION); $this->resultSet->buffer(); } @@ -261,7 +261,7 @@ public function testInvalidDataSourceRaisesException(mixed $dataSource): void } // Verify invalid data source throws TypeError - $this->expectException(TypeError::class); + self::expectException(TypeError::class); $this->resultSet->initialize($dataSource); } @@ -309,7 +309,7 @@ public function testRowObjectPrototypeMayBePassedToConstructor(): void public function testSettingInvalidReturnTypeRaisesException(mixed $type): void { // Verify invalid return type throws TypeError - $this->expectException(TypeError::class); + self::expectException(TypeError::class); new ResultSet(ResultSet::TYPE_ARRAYOBJECT, $type); }