diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 94237c15..63b2de6f 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -14,6 +14,8 @@ return [ Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class, Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class, Command\ShortUrl\DeleteShortUrlVisitsCommand::NAME => Command\ShortUrl\DeleteShortUrlVisitsCommand::class, + Command\ShortUrl\DeleteExpiredShortUrlsCommand::NAME => + Command\ShortUrl\DeleteExpiredShortUrlsCommand::class, Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class, Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 282a1db5..875c8226 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -45,6 +45,7 @@ return [ Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\DeleteShortUrlVisitsCommand::class => ConfigAbstractFactory::class, + Command\ShortUrl\DeleteExpiredShortUrlsCommand::class => ConfigAbstractFactory::class, Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class, Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class, @@ -96,6 +97,7 @@ return [ Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\ShortUrl\DeleteShortUrlCommand::class => [ShortUrl\DeleteShortUrlService::class], Command\ShortUrl\DeleteShortUrlVisitsCommand::class => [ShortUrl\ShortUrlVisitsDeleter::class], + Command\ShortUrl\DeleteExpiredShortUrlsCommand::class => [ShortUrl\DeleteShortUrlService::class], Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class], Command\Visit\LocateVisitsCommand::class => [ diff --git a/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php new file mode 100644 index 00000000..109beff7 --- /dev/null +++ b/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php @@ -0,0 +1,75 @@ +setName(self::NAME) + ->setDescription( + 'Deletes all short URLs that are considered expired, because they have a validUntil date in the past', + ) + ->addOption( + 'evaluate-max-visits', + mode: InputOption::VALUE_NONE, + description: 'Also take into consideration short URLs which have reached their max amount of visits.', + ) + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Delete short URLs with no confirmation') + ->addOption( + 'dry-run', + mode: InputOption::VALUE_NONE, + description: 'Delete short URLs with no confirmation', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $force = $input->getOption('force') || ! $input->isInteractive(); + $dryRun = $input->getOption('dry-run'); + $conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $input->getOption('evaluate-max-visits')); + + if (! $force && ! $dryRun) { + $io->warning([ + 'Careful!', + 'You are about to perform a destructive operation that can result in deleted short URLs and visits.', + 'This action cannot be undone. Proceed at your own risk', + ]); + if (! $io->confirm('Continue?', default: false)) { + return ExitCode::EXIT_WARNING; + } + } + + if ($dryRun) { + $result = $this->deleteShortUrlService->countExpiredShortUrls($conditions); + $io->success(sprintf('There are %s expired short URLs matching provided conditions', $result)); + return ExitCode::EXIT_SUCCESS; + } + + $result = $this->deleteShortUrlService->deleteExpiredShortUrls($conditions); + $io->success(sprintf('%s expired short URLs have been deleted', $result)); + return ExitCode::EXIT_SUCCESS; + } +} diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index c78ce31a..5f3d8fae 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -151,6 +151,7 @@ return [ 'em', Options\DeleteShortUrlsOptions::class, ShortUrl\ShortUrlResolver::class, + ShortUrl\Repository\ExpiredShortUrlsRepository::class, ], ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class], ShortUrl\ShortUrlVisitsDeleter::class => [ diff --git a/module/Core/src/ShortUrl/DeleteShortUrlService.php b/module/Core/src/ShortUrl/DeleteShortUrlService.php index 2a39e695..b65381aa 100644 --- a/module/Core/src/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/ShortUrl/DeleteShortUrlService.php @@ -8,15 +8,18 @@ use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ExpiredShortUrlsRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class DeleteShortUrlService implements DeleteShortUrlServiceInterface +readonly class DeleteShortUrlService implements DeleteShortUrlServiceInterface { public function __construct( - private readonly EntityManagerInterface $em, - private readonly DeleteShortUrlsOptions $deleteShortUrlsOptions, - private readonly ShortUrlResolverInterface $urlResolver, + private EntityManagerInterface $em, + private DeleteShortUrlsOptions $deleteShortUrlsOptions, + private ShortUrlResolverInterface $urlResolver, + private ExpiredShortUrlsRepositoryInterface $expiredShortUrlsRepository, ) { } @@ -47,4 +50,14 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface $this->deleteShortUrlsOptions->visitsThreshold, ); } + + public function deleteExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int + { + return $this->expiredShortUrlsRepository->delete($conditions); + } + + public function countExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int + { + return $this->expiredShortUrlsRepository->dryCount($conditions); + } } diff --git a/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php b/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php index 0a7420f1..32eaffa1 100644 --- a/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php +++ b/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Core\Exception; +use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -19,4 +20,14 @@ interface DeleteShortUrlServiceInterface bool $ignoreThreshold = false, ?ApiKey $apiKey = null, ): void; + + /** + * Deletes short URLs that are considered expired based on provided conditions + */ + public function deleteExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int; + + /** + * Counts short URLs that are considered expired based on provided conditions, without really deleting them + */ + public function countExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int; } diff --git a/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php b/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php index 565b9e98..d4f0c063 100644 --- a/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php +++ b/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php @@ -10,14 +10,6 @@ final readonly class ExpiredShortUrlsConditions { } - public static function fromQuery(array $query): self - { - return new self( - pastValidUntil: (bool) ($query['pastValidUntil'] ?? true), - maxVisitsReached: (bool) ($query['maxVisitsReached'] ?? true), - ); - } - public function hasConditions(): bool { return $this->pastValidUntil || $this->maxVisitsReached; diff --git a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php index 6d8aa0df..0b796971 100644 --- a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php +++ b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php @@ -18,7 +18,7 @@ class ExpiredShortUrlsRepository extends EntitySpecificationRepository implement /** * @inheritDoc */ - public function delete(ExpiredShortUrlsConditions $conditions = new ExpiredShortUrlsConditions()): int + public function delete(ExpiredShortUrlsConditions $conditions): int { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->delete(ShortUrl::class, 's'); @@ -29,7 +29,7 @@ class ExpiredShortUrlsRepository extends EntitySpecificationRepository implement /** * @inheritDoc */ - public function dryCount(ExpiredShortUrlsConditions $conditions = new ExpiredShortUrlsConditions()): int + public function dryCount(ExpiredShortUrlsConditions $conditions): int { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->select('COUNT(s.id)') diff --git a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php index e82c3e43..96032065 100644 --- a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php @@ -11,10 +11,10 @@ interface ExpiredShortUrlsRepositoryInterface /** * Delete expired short URLs matching provided conditions */ - public function delete(ExpiredShortUrlsConditions $conditions = new ExpiredShortUrlsConditions()): int; + public function delete(ExpiredShortUrlsConditions $conditions): int; /** * Count how many expired short URLs would be deleted for provided conditions */ - public function dryCount(ExpiredShortUrlsConditions $conditions = new ExpiredShortUrlsConditions()): int; + public function dryCount(ExpiredShortUrlsConditions $conditions): int; } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index b639ace4..8aac9b73 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -16,7 +16,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; -use Shlinkio\Shlink\Core\Visit\Entity\Visit; use function Shlinkio\Shlink\Core\ArrayUtils\map; use function sprintf; diff --git a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php index 3ac9897c..4788818e 100644 --- a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php @@ -13,7 +13,9 @@ use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException; use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlService; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ExpiredShortUrlsRepository; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; @@ -26,6 +28,7 @@ class DeleteShortUrlServiceTest extends TestCase { private MockObject & EntityManagerInterface $em; private MockObject & ShortUrlResolverInterface $urlResolver; + private MockObject & ExpiredShortUrlsRepository $expiredShortUrlsRepository; private string $shortCode; protected function setUp(): void @@ -39,6 +42,8 @@ class DeleteShortUrlServiceTest extends TestCase $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); $this->urlResolver->method('resolveShortUrl')->willReturn($shortUrl); + + $this->expiredShortUrlsRepository = $this->createMock(ExpiredShortUrlsRepository::class); } #[Test] @@ -94,11 +99,33 @@ class DeleteShortUrlServiceTest extends TestCase $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode)); } + #[Test] + public function deleteExpiredShortUrlsDelegatesToRepository(): void + { + $conditions = new ExpiredShortUrlsConditions(); + $this->expiredShortUrlsRepository->expects($this->once())->method('delete')->with($conditions)->willReturn(5); + + $result = $this->createService()->deleteExpiredShortUrls($conditions); + + self::assertEquals(5, $result); + } + + #[Test] + public function countExpiredShortUrlsDelegatesToRepository(): void + { + $conditions = new ExpiredShortUrlsConditions(); + $this->expiredShortUrlsRepository->expects($this->once())->method('dryCount')->with($conditions)->willReturn(2); + + $result = $this->createService()->countExpiredShortUrls($conditions); + + self::assertEquals(2, $result); + } + private function createService(bool $checkVisitsThreshold = true, int $visitsThreshold = 5): DeleteShortUrlService { return new DeleteShortUrlService($this->em, new DeleteShortUrlsOptions( $visitsThreshold, $checkVisitsThreshold, - ), $this->urlResolver); + ), $this->urlResolver, $this->expiredShortUrlsRepository); } }