diff --git a/src/cmd/Console.php b/src/cmd/Console.php index a7ef0bd..144a262 100644 --- a/src/cmd/Console.php +++ b/src/cmd/Console.php @@ -6,9 +6,7 @@ use Exception; use Symfony\Component\Console\Application; -use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\CommandLoader\FactoryCommandLoader; -use Symfony\Component\Console\Output\ConsoleOutput; use dbschemix\migrator\cmd\presentation\CreateCommand; use dbschemix\migrator\cmd\presentation\DownCommand; use dbschemix\migrator\cmd\presentation\FixtureCommand; @@ -20,6 +18,9 @@ final readonly class Console { + /** + * @throws Exception if Symfony Console fails to run the application + */ public static function run(MigratorInterface $migrator): never { $console = new Application(); @@ -37,13 +38,10 @@ public static function run(MigratorInterface $migrator): never ) ); - $output = new ConsoleOutput(); - - try { - exit($console->run()); - } catch (Exception $e) { - $output->writeln($e->getMessage()); - exit(Command::FAILURE); - } + // Application::run() catches uncaught throwables, renders them + // (exception class + message, full stack trace under -v) to stderr, + // and returns a non-zero exit code. We must not wrap it in our own + // catch: doing so swallows the type and trace Symfony would render. + exit($console->run()); } } diff --git a/src/cmd/presentation/CreateCommand.php b/src/cmd/presentation/CreateCommand.php index 85caba4..0b83933 100644 --- a/src/cmd/presentation/CreateCommand.php +++ b/src/cmd/presentation/CreateCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -16,6 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; #[AsCommand( name: 'migrate:create', @@ -51,10 +51,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } catch (InvalidArgumentException | MigratorException $e) { $output->writeln($e->getMessage()); return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/DownCommand.php b/src/cmd/presentation/DownCommand.php index ba0da25..d6aa879 100644 --- a/src/cmd/presentation/DownCommand.php +++ b/src/cmd/presentation/DownCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -13,9 +12,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use dbschemix\core\exception\InitializationException; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\MigratorExceptionReporter; #[AsCommand( name: 'migrate:down', @@ -24,6 +24,7 @@ final class DownCommand extends Command { use CommandOptions; + use MigratorExceptionReporter; /** * @throws LogicException @@ -66,15 +67,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->migrator->down($this->getOptions($input)); } catch (InvalidArgumentException | MigratorException $e) { - if ($e instanceof InitializationException) { - $output->writeln('Calling the command "migrate:init" may help fix the error.'); - } - $output->writeln($e->getMessage()); - return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; + return $this->reportRecoverableFailure($e, $output); } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/FixtureCommand.php b/src/cmd/presentation/FixtureCommand.php index b8c20cc..b57d085 100644 --- a/src/cmd/presentation/FixtureCommand.php +++ b/src/cmd/presentation/FixtureCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -15,6 +14,7 @@ use Symfony\Component\Console\Output\OutputInterface; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; #[AsCommand( name: 'migrate:fixture', @@ -61,10 +61,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } catch (InvalidArgumentException | MigratorException $e) { $output->writeln($e->getMessage()); return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/InitCommand.php b/src/cmd/presentation/InitCommand.php index 4aca8b2..3a33cc5 100644 --- a/src/cmd/presentation/InitCommand.php +++ b/src/cmd/presentation/InitCommand.php @@ -4,7 +4,6 @@ namespace dbschemix\migrator\cmd\presentation; -use Throwable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\LogicException; @@ -34,10 +33,11 @@ public function __invoke(InputInterface $input, OutputInterface $output): int } catch (MigratorException $e) { $output->writeln($e->getMessage()); return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/RedoCommand.php b/src/cmd/presentation/RedoCommand.php index d8173ad..1eb94b0 100644 --- a/src/cmd/presentation/RedoCommand.php +++ b/src/cmd/presentation/RedoCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -13,9 +12,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use dbschemix\core\exception\InitializationException; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\MigratorExceptionReporter; #[AsCommand( name: 'migrate:redo', @@ -24,6 +24,7 @@ final class RedoCommand extends Command { use CommandOptions; + use MigratorExceptionReporter; /** * @throws LogicException @@ -66,15 +67,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->migrator->redo($this->getOptions($input)); } catch (InvalidArgumentException | MigratorException $e) { - if ($e instanceof InitializationException) { - $output->writeln('Calling the command "migrate:init" may help fix the error.'); - } - $output->writeln($e->getMessage()); - return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; + return $this->reportRecoverableFailure($e, $output); } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/UpCommand.php b/src/cmd/presentation/UpCommand.php index 2676f25..ede2054 100644 --- a/src/cmd/presentation/UpCommand.php +++ b/src/cmd/presentation/UpCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -13,9 +12,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use dbschemix\core\exception\InitializationException; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\MigratorExceptionReporter; #[AsCommand( name: 'migrate:up', @@ -24,6 +24,7 @@ final class UpCommand extends Command { use CommandOptions; + use MigratorExceptionReporter; /** * @throws LogicException @@ -74,15 +75,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->migrator->up($this->getOptions($input)); } catch (InvalidArgumentException | MigratorException $e) { - if ($e instanceof InitializationException) { - $output->writeln('Calling the command "migrate:init" may help fix the error.'); - } - $output->writeln($e->getMessage()); - return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; + return $this->reportRecoverableFailure($e, $output); } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/VerifyCommand.php b/src/cmd/presentation/VerifyCommand.php index 88e040a..97b1de5 100644 --- a/src/cmd/presentation/VerifyCommand.php +++ b/src/cmd/presentation/VerifyCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -13,9 +12,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use dbschemix\core\exception\InitializationException; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\MigratorExceptionReporter; #[AsCommand( name: 'migrate:verify', @@ -24,6 +24,7 @@ final class VerifyCommand extends Command { use CommandOptions; + use MigratorExceptionReporter; /** * @throws LogicException @@ -60,15 +61,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->migrator->verify($this->getOptions($input)); } catch (InvalidArgumentException | MigratorException $e) { - if ($e instanceof InitializationException) { - $output->writeln('Calling the command "migrate:init" may help fix the error.'); - } - $output->writeln($e->getMessage()); - return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; + return $this->reportRecoverableFailure($e, $output); } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/CommandOptions.php b/src/cmd/presentation/support/CommandOptions.php similarity index 82% rename from src/cmd/presentation/CommandOptions.php rename to src/cmd/presentation/support/CommandOptions.php index a503610..c3b7204 100644 --- a/src/cmd/presentation/CommandOptions.php +++ b/src/cmd/presentation/support/CommandOptions.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace dbschemix\migrator\cmd\presentation; +namespace dbschemix\migrator\cmd\presentation\support; use InvalidArgumentException; use Symfony\Component\Console\Input\InputInterface; @@ -48,8 +48,17 @@ protected function getOptions(InputInterface $input): InputOptions } /** + * Normalizes the raw CLI option into the type required by the core + * contract. The "limit" business rule itself is owned by + * {@see InputOptions}, which declares the parameter as a + * non-negative-int; this method only guarantees the transport value + * can satisfy that contract before it reaches the core, so a malformed + * option is rejected at the edge instead of producing an out-of-contract + * value downstream. + * * @return non-negative-int - * @throws InvalidArgumentException + * @throws InvalidArgumentException if the option cannot be represented + * as the core contract's non-negative-int */ private function getOptionLimit(InputInterface $input): int { diff --git a/src/cmd/presentation/support/MigratorExceptionReporter.php b/src/cmd/presentation/support/MigratorExceptionReporter.php new file mode 100644 index 0000000..a257aee --- /dev/null +++ b/src/cmd/presentation/support/MigratorExceptionReporter.php @@ -0,0 +1,34 @@ +writeln('Calling the command "migrate:init" may help fix the error.'); + } + + $output->writeln($e->getMessage()); + + return Command::INVALID; + } +} diff --git a/tests/cmd/BootstrapTest.php b/tests/cmd/BootstrapTest.php index 772d286..07d4597 100644 --- a/tests/cmd/BootstrapTest.php +++ b/tests/cmd/BootstrapTest.php @@ -33,7 +33,6 @@ public function assert_migrator_throws_when_config_returned_null(): void { // Given / When / Then $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('return $migrator'); Bootstrap::assertMigrator(null); } @@ -43,7 +42,6 @@ public function assert_migrator_throws_when_config_returned_other_object(): void { // Given / When / Then $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('MigratorInterface'); Bootstrap::assertMigrator(new stdClass()); } diff --git a/tests/cmd/presentation/CommandOptionsTest.php b/tests/cmd/presentation/CommandOptionsTest.php index cbde640..ee282cb 100644 --- a/tests/cmd/presentation/CommandOptionsTest.php +++ b/tests/cmd/presentation/CommandOptionsTest.php @@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Tester\CommandTester; use dbschemix\core\InputOptions; -use dbschemix\migrator\cmd\presentation\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; #[CoversTrait(CommandOptions::class)] final class CommandOptionsTest extends TestCase diff --git a/tests/cmd/presentation/CommandOptionsTestFixture.php b/tests/cmd/presentation/CommandOptionsTestFixture.php index 8a3f8ee..c0ebf40 100644 --- a/tests/cmd/presentation/CommandOptionsTestFixture.php +++ b/tests/cmd/presentation/CommandOptionsTestFixture.php @@ -9,7 +9,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use dbschemix\core\InputOptions; -use dbschemix\migrator\cmd\presentation\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; /** * Concrete fixture command that uses the CommandOptions trait and exposes diff --git a/tests/cmd/presentation/CreateCommandTest.php b/tests/cmd/presentation/CreateCommandTest.php index 02ca7bb..fa0fe87 100644 --- a/tests/cmd/presentation/CreateCommandTest.php +++ b/tests/cmd/presentation/CreateCommandTest.php @@ -70,7 +70,7 @@ public function invalid_argument_exception_returns_invalid_and_writes_message(): } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -78,11 +78,14 @@ public function generic_throwable_returns_failure(): void $command = new CreateCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute(['name' => 'some_migration']); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute(['name' => 'some_migration']); } #[Test] diff --git a/tests/cmd/presentation/DownCommandTest.php b/tests/cmd/presentation/DownCommandTest.php index e112f3f..e3fab23 100644 --- a/tests/cmd/presentation/DownCommandTest.php +++ b/tests/cmd/presentation/DownCommandTest.php @@ -90,7 +90,7 @@ public function initialization_exception_writes_hint_and_returns_invalid(): void } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -98,10 +98,13 @@ public function generic_throwable_returns_failure(): void $command = new DownCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute([]); } } diff --git a/tests/cmd/presentation/FixtureCommandTest.php b/tests/cmd/presentation/FixtureCommandTest.php index 554a214..c686d4d 100644 --- a/tests/cmd/presentation/FixtureCommandTest.php +++ b/tests/cmd/presentation/FixtureCommandTest.php @@ -69,7 +69,7 @@ public function invalid_argument_exception_returns_invalid_and_writes_message(): } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -77,10 +77,13 @@ public function generic_throwable_returns_failure(): void $command = new FixtureCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute([]); } } diff --git a/tests/cmd/presentation/InitCommandTest.php b/tests/cmd/presentation/InitCommandTest.php index 4f127b0..f6388c3 100644 --- a/tests/cmd/presentation/InitCommandTest.php +++ b/tests/cmd/presentation/InitCommandTest.php @@ -52,7 +52,7 @@ public function migrator_exception_returns_invalid_and_writes_message(): void } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -60,10 +60,13 @@ public function generic_throwable_returns_failure(): void $command = new InitCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute([]); } } diff --git a/tests/cmd/presentation/RedoCommandTest.php b/tests/cmd/presentation/RedoCommandTest.php index 08504f0..328decd 100644 --- a/tests/cmd/presentation/RedoCommandTest.php +++ b/tests/cmd/presentation/RedoCommandTest.php @@ -90,7 +90,7 @@ public function initialization_exception_writes_hint_and_returns_invalid(): void } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -98,10 +98,13 @@ public function generic_throwable_returns_failure(): void $command = new RedoCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute([]); } } diff --git a/tests/cmd/presentation/UpCommandTest.php b/tests/cmd/presentation/UpCommandTest.php index 74f4165..e8f70e5 100644 --- a/tests/cmd/presentation/UpCommandTest.php +++ b/tests/cmd/presentation/UpCommandTest.php @@ -90,7 +90,7 @@ public function initialization_exception_writes_hint_and_returns_invalid(): void } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -98,10 +98,13 @@ public function generic_throwable_returns_failure(): void $command = new UpCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected error'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute([]); } } diff --git a/tests/cmd/presentation/VerifyCommandTest.php b/tests/cmd/presentation/VerifyCommandTest.php index 8b1295a..6e26c14 100644 --- a/tests/cmd/presentation/VerifyCommandTest.php +++ b/tests/cmd/presentation/VerifyCommandTest.php @@ -90,7 +90,7 @@ public function initialization_exception_writes_hint_and_returns_invalid(): void } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed(): void { // Given $migrator = new FakeMigrator(); @@ -98,10 +98,10 @@ public function generic_throwable_returns_failure(): void $command = new VerifyCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); - // Then - self::assertSame(Command::FAILURE, $exitCode); + $this->expectException(RuntimeException::class); + + // When + $tester->execute([]); } }