diff --git a/composer.json b/composer.json index 62972d9..56b6fe7 100644 --- a/composer.json +++ b/composer.json @@ -22,13 +22,14 @@ }, "require-dev": { "doctrine/cache": "^1.12", - "doctrine/orm": "^2.9 || ^3.0", "doctrine/dbal": "^3.8 || ^4.0", + "doctrine/orm": "^2.9 || ^3.0", "phpunit/phpunit": "^9.0", "psr/container": "1.0.0", "ramsey/uuid-doctrine": "^1.6", - "sbooker/enumerable-doctrine": "^1.1", - "sbooker/doctrine-transaction-handler": "^2.2" + "sbooker/console": "^1.1", + "sbooker/doctrine-transaction-handler": "^2.2", + "sbooker/enumerable-doctrine": "^1.1" }, "suggest": { "doctrine/orm": "If you want use DB persistence with Doctrine", diff --git a/src/CleanStorage.php b/src/CleanStorage.php new file mode 100644 index 0000000..95f9d7a --- /dev/null +++ b/src/CleanStorage.php @@ -0,0 +1,9 @@ +cleaner = $cleaner; + } + + protected function configure() + { + $this->setDescription('Clean used commands in bus.'); + $this->addArgument('success', InputArgument::OPTIONAL, "DateTime to which all SUCCESS commands will be deleted"); + $this->addArgument('failed', InputArgument::OPTIONAL, "DateTime to which all FAILED commands will be deleted"); + } + + protected function doExecute(InputInterface $input, OutputInterface $output): void + { + $this->cleaner->clean($this->resolveArgument($input, 'success'), $this->resolveArgument($input, 'failed')); + } + + private function resolveArgument(InputInterface $input, string $name): ?\DateTimeImmutable + { + $argument = $input->getArgument($name); + + return (null === $argument) ? null : new \DateTimeImmutable($argument); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/DoctrineRepository.php b/src/Infrastructure/Persistence/DoctrineRepository.php index d93e525..bc7c261 100644 --- a/src/Infrastructure/Persistence/DoctrineRepository.php +++ b/src/Infrastructure/Persistence/DoctrineRepository.php @@ -15,12 +15,13 @@ use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\Persistence\Mapping\MappingException; use Ramsey\Uuid\UuidInterface; +use Sbooker\CommandBus\CleanStorage; use Sbooker\CommandBus\Command; use Sbooker\CommandBus\ReadStorage; use Sbooker\CommandBus\Status; use Sbooker\CommandBus\WriteStorage; -final class DoctrineRepository extends EntityRepository implements WriteStorage, ReadStorage +final class DoctrineRepository extends EntityRepository implements WriteStorage, ReadStorage, CleanStorage { public function get(UuidInterface $id): ?Command { @@ -147,4 +148,41 @@ private function getConnection(): Connection { return $this->getEntityManager()->getConnection(); } + + /** + * @throws DBALException + * @throws MappingException + * @throws \ReflectionException + */ + public function cleanSuccessCommands(\DateTimeImmutable $before): void + { + $this->clean(Status::success(), $before); + } + + /** + * @throws DBALException + * @throws MappingException + * @throws \ReflectionException + */ + public function cleanFailedCommands(\DateTimeImmutable $before): void + { + $this->clean(Status::fail(), $before); + } + + /** + * @throws DBALException + * @throws MappingException + * @throws \ReflectionException + */ + private function clean(Status $status, \DateTimeImmutable $before): void + { + $qb = $this->getConnection()->createQueryBuilder(); + + $qb->delete($this->getTableName(), 'c') + ->andWhere('c.status = :status') + ->andWhere('c.next_attempt_at < :before') + ->setParameter('status', $status->getRawValue()) + ->setParameter('before', $before->format($this->getPlatform()->getDateTimeTzFormatString())) + ->executeStatement(); + } } \ No newline at end of file diff --git a/src/PersistentCommandBusCleaner.php b/src/PersistentCommandBusCleaner.php new file mode 100644 index 0000000..edb2f34 --- /dev/null +++ b/src/PersistentCommandBusCleaner.php @@ -0,0 +1,38 @@ +storage = $storage; + $this->successBefore = $successBefore; + $this->failedBefore = $failedBefore; + } + + public function clean(?\DateTimeImmutable $successBefore = null, ?\DateTimeImmutable $failedBefore = null): void + { + $successBefore = $this->selectFrom($successBefore, $this->successBefore); + if (null !== $successBefore) { + $this->storage->cleanSuccessCommands($successBefore); + } + $failedBefore = $this->selectFrom($failedBefore, $this->failedBefore); + if (null !== $failedBefore) { + $this->storage->cleanFailedCommands($failedBefore); + } + } + + private function selectFrom(?\DateTimeImmutable $first, ?\DateTimeImmutable $second):?\DateTimeImmutable + { + if (null !== $first) { + return $first; + } + + return $second; + } +} \ No newline at end of file diff --git a/tests/Infrastructure/Persistence/DoctrineRepositoryTest.php b/tests/Infrastructure/Persistence/DoctrineRepositoryTest.php index afb2578..62decce 100644 --- a/tests/Infrastructure/Persistence/DoctrineRepositoryTest.php +++ b/tests/Infrastructure/Persistence/DoctrineRepositoryTest.php @@ -8,6 +8,7 @@ use Ramsey\Uuid\UuidInterface; use Sbooker\CommandBus\AttemptCounter; use Sbooker\CommandBus\Command; +use Sbooker\CommandBus\Status; use Sbooker\CommandBus\Workflow; class DoctrineRepositoryTest extends PersistenceTestCase @@ -66,6 +67,7 @@ public function testGetFirstToProcessAndLock(string $db): void $expectedCommandName = 'command.name'; $otherCommandName = 'other.command.name'; + $expectedCommand = $this->createCommand(Uuid::uuid4(), $expectedCommandName, '-10seconds'); $secondCommand = $this->createCommand(Uuid::uuid4(), $otherCommandName, '-5seconds'); $this->makeFixtures($expectedCommand); @@ -81,6 +83,35 @@ public function testGetFirstToProcessAndLock(string $db): void $this->tearDownDbDeps($em); } + + /** + * @dataProvider dbs + */ + public function testCleanSuccess(string $db) + { + $em = $this->setUpDbDeps($db); + + $repository = $this->getRepository($em); + $commandId = Uuid::uuid4(); + $otherCommandId = Uuid::uuid4(); + $commandName = 'command.name'; + $command = $this->createCommandWithStatus($commandId, $commandName, Status::success(),'-2days'); + $otherCommand = $this->createCommandWithStatus($otherCommandId, $commandName, Status::fail(), '-5days'); + $this->makeFixtures($command); + $this->makeFixtures($otherCommand); + + $repository->cleanFailedCommands(new \DateTimeImmutable('-3days')); + + $this->assertNotNull($repository->get($commandId)); + $this->assertNull($repository->get($otherCommandId)); + + $em->clear(); + + $repository->cleanSuccessCommands(new \DateTimeImmutable('-1day')); + + $this->assertNull($repository->get($commandId)); + } + private function assertCommandEquals(Command $expected, Command $given): void { $this->assertUuidEquals( diff --git a/tests/Infrastructure/Persistence/PersistenceTestCase.php b/tests/Infrastructure/Persistence/PersistenceTestCase.php index 27a0deb..399bda3 100644 --- a/tests/Infrastructure/Persistence/PersistenceTestCase.php +++ b/tests/Infrastructure/Persistence/PersistenceTestCase.php @@ -7,13 +7,15 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\Tools\SchemaTool; use Ramsey\Uuid\UuidInterface; +use Sbooker\CommandBus\AttemptCounter; use Sbooker\CommandBus\Command; use Sbooker\CommandBus\Infrastructure\Persistence\DoctrineRepository; use Sbooker\CommandBus\NormalizedCommand; use Sbooker\CommandBus\Normalizer; +use Sbooker\CommandBus\Status; +use Sbooker\CommandBus\Tests\TestCase; use Sbooker\TransactionManager\DoctrineTransactionHandler; use Sbooker\TransactionManager\TransactionManager; -use Sbooker\CommandBus\Tests\TestCase; abstract class PersistenceTestCase extends TestCase { @@ -51,6 +53,18 @@ final protected function createCommand(UuidInterface $commandId, string $command return $command; } + final protected function createCommandWithStatus(UuidInterface $commandId, string $commandName, Status $status, string $nextAttemptAt = 'now'): Command + { + $command = new Command($commandId, new \stdClass(), $this->createNormalizer($commandName)); + /** @var AttemptCounter $attemptCounter */ + $attemptCounter = $this->getPrivatePropertyValue($command, 'attemptCounter'); + $this->openProperty($attemptCounter, 'nextAttemptAt')->setValue($attemptCounter, new \DateTimeImmutable($nextAttemptAt)); + $workflow = $this->getPrivatePropertyValue( $command,'workflow'); + $this->openProperty($workflow, 'status')->setValue($workflow, $status); + + return $command; + } + final protected function makeFixtures(object ... $objects): void { $this->getTransactionManager()->transactional(function () use ($objects) { diff --git a/tests/PersistentCommandBusCleanerTest.php b/tests/PersistentCommandBusCleanerTest.php new file mode 100644 index 0000000..533babf --- /dev/null +++ b/tests/PersistentCommandBusCleanerTest.php @@ -0,0 +1,88 @@ +createCleaner( + $this->createStorage($expectedSuccess, $expectedFail), + $configuredSuccess, + $configuredFail + ); + + $cleaner->clean($this->makeDateTime($inputSuccess), $this->makeDateTime($inputFail)); + } + + public static function examples(): array + { + return [ + 'all empty' => [ null, null, null, null, null, null, ], + 'input success only' => [ '2024-01-01 00:00:00', null, null, null, '2024-01-01 00:00:00', null ], + 'configured success only' => [ null, '2024-01-01 00:00:00', null, null, '2024-01-01 00:00:00', null ], + 'both success only' => [ '2024-01-01 00:00:00', '2023-01-01 00:00:00', null, null, '2024-01-01 00:00:00', null ], + 'input failed only' => [ null, null, '2024-01-01 00:00:00', null, null, '2024-01-01 00:00:00' ], + 'both input' => [ '2024-01-01 00:00:00', null, '2023-01-01 00:00:00', null, '2024-01-01 00:00:00', '2023-01-01 00:00:00' ], + 'configured success input failed' =>[ null, '2024-01-01 00:00:00', '2023-01-01 00:00:00', null, '2024-01-01 00:00:00', '2023-01-01 00:00:00' ], + 'both success only input failed' => [ '2024-01-01 00:00:00', '2023-01-01 00:00:00', '2022-01-01 00:00:00', null, '2024-01-01 00:00:00', '2022-01-01 00:00:00', ], + 'configured failed only' => [ null, null, null, '2024-01-01 00:00:00', null, '2024-01-01 00:00:00' ], + 'input success configured failed' => [ '2024-01-01 00:00:00', null, null, '2021-01-01 00:00:00', '2024-01-01 00:00:00', '2021-01-01 00:00:00' ], + 'configured success configured failed' => [ null, '2024-01-01 00:00:00', null, '2021-01-01 00:00:00', '2024-01-01 00:00:00', '2021-01-01 00:00:00' ], + 'both success configured failed' => [ '2024-01-01 00:00:00', '2023-01-01 00:00:00', null, '2021-01-01 00:00:00', '2024-01-01 00:00:00', '2021-01-01 00:00:00' ], + 'both failed only' => [ null, null, '2024-01-01 00:00:00', '2023-01-01 00:00:00', null, '2024-01-01 00:00:00', ], + 'input success both failed' => [ '2025-01-01 00:00:00', null, '2024-01-01 00:00:00', '2023-01-01 00:00:00', '2025-01-01 00:00:00', '2024-01-01 00:00:00', ], + 'configured success both failed' => [ null, '2025-01-01 00:00:00', '2024-01-01 00:00:00', '2023-01-01 00:00:00', '2025-01-01 00:00:00', '2024-01-01 00:00:00', ], + 'both success & failed' => [ '2026-01-01 00:00:00', '2025-01-01 00:00:00', '2024-01-01 00:00:00', '2023-01-01 00:00:00', '2026-01-01 00:00:00', '2024-01-01 00:00:00', ], + ]; + } + + private function createCleaner(CleanStorage $storage, ?string $configuredSuccess, ?string $configuredFail): CommandBusCleaner + { + return new PersistentCommandBusCleaner( + $storage, + $this->makeDateTime($configuredSuccess), + $this->makeDateTime($configuredFail), + ); + } + + private function createStorage(?string $expectedSuccess, ?string $expectedFail): CleanStorage + { + $mock = $this->createMock(CleanStorage::class); + $mock + ->expects($this->exactly($this->resolveCount($expectedSuccess))) + ->method('cleanSuccessCommands') + ->with($this->makeDateTime($expectedSuccess)); + $mock + ->expects($this->exactly($this->resolveCount($expectedFail))) + ->method('cleanFailedCommands') + ->with($this->makeDateTime($expectedFail)); + + return $mock; + } + + private function makeDateTime(?string $dateTime): ?\DateTimeImmutable + { + if (null === $dateTime) { + return null; + } + + return new \DateTimeImmutable($dateTime); + } + + private function resolveCount(?string $dateTime): int + { + if (null === $dateTime) { + return 0; + } + + return 1; + } +} \ No newline at end of file