Add command to delete expired short URLs

This commit is contained in:
Alejandro Celaya 2024-04-03 18:57:09 +02:00
parent fd882834d3
commit f2371e8a80
11 changed files with 140 additions and 18 deletions

View file

@ -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,

View file

@ -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 => [

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class DeleteExpiredShortUrlsCommand extends Command
{
public const NAME = 'short-url:delete-expired';
public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService)
{
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}

View file

@ -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 => [

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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)')

View file

@ -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;
}

View file

@ -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;

View file

@ -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);
}
}