diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 5437555b..c78ce31a 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -57,6 +57,10 @@ return [ EntityRepositoryFactory::class, ShortUrl\Entity\ShortUrl::class, ], + ShortUrl\Repository\ExpiredShortUrlsRepository::class => [ + EntityRepositoryFactory::class, + ShortUrl\Entity\ShortUrl::class, + ], Tag\TagService::class => ConfigAbstractFactory::class, diff --git a/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php b/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php new file mode 100644 index 00000000..565b9e98 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/ExpiredShortUrlsConditions.php @@ -0,0 +1,25 @@ +pastValidUntil || $this->maxVisitsReached; + } +} diff --git a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php new file mode 100644 index 00000000..6d8aa0df --- /dev/null +++ b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepository.php @@ -0,0 +1,77 @@ +getEntityManager()->createQueryBuilder(); + $qb->delete(ShortUrl::class, 's'); + + return $this->applyConditions($qb, $conditions, fn () => (int) $qb->getQuery()->execute()); + } + + /** + * @inheritDoc + */ + public function dryCount(ExpiredShortUrlsConditions $conditions = new ExpiredShortUrlsConditions()): int + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('COUNT(s.id)') + ->from(ShortUrl::class, 's'); + + return $this->applyConditions($qb, $conditions, fn () => (int) $qb->getQuery()->getSingleScalarResult()); + } + + /** + * @param callable(): int $getResultFromQueryBuilder + */ + private function applyConditions( + QueryBuilder $qb, + ExpiredShortUrlsConditions $conditions, + callable $getResultFromQueryBuilder, + ): int { + if (! $conditions->hasConditions()) { + return 0; + } + + if ($conditions->pastValidUntil) { + $qb + ->where($qb->expr()->andX( + $qb->expr()->isNotNull('s.validUntil'), + $qb->expr()->lt('s.validUntil', ':now'), + )) + ->setParameter('now', Chronos::now()->toDateTimeString()); + } + + if ($conditions->maxVisitsReached) { + $qb->orWhere($qb->expr()->andX( + $qb->expr()->isNotNull('s.maxVisits'), + $qb->expr()->lte( + 's.maxVisits', + sprintf( + '(SELECT COALESCE(SUM(vc.count), 0) FROM %s as vc WHERE vc.shortUrl=s)', + ShortUrlVisitsCount::class, + ), + ), + )); + } + + return $getResultFromQueryBuilder(); + } +} diff --git a/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php new file mode 100644 index 00000000..e82c3e43 --- /dev/null +++ b/module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php @@ -0,0 +1,20 @@ +getEntityManager(); + $this->repository = new ExpiredShortUrlsRepository($em, $em->getClassMetadata(ShortUrl::class)); + } + + #[Test] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: false), 0])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: false), 7])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: true), 6])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: true), 9])] + public function deletesExpectedAmountOfShortUrls( + ExpiredShortUrlsConditions $conditions, + int $expectedDeletedShortUrls, + ): void { + $createdShortUrls = $this->createDataSet(); + + self::assertEquals($expectedDeletedShortUrls, $this->repository->delete($conditions)); + self::assertEquals( + $createdShortUrls - $expectedDeletedShortUrls, + $this->getEntityManager()->getRepository(ShortUrl::class)->count(), + ); + } + + #[Test] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: false), 0])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: false), 7])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: true), 6])] + #[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: true), 9])] + public function countsExpectedAmountOfShortUrls( + ExpiredShortUrlsConditions $conditions, + int $expectedShortUrlsCount, + ): void { + $createdShortUrls = $this->createDataSet(); + + self::assertEquals($expectedShortUrlsCount, $this->repository->dryCount($conditions)); + self::assertEquals($createdShortUrls, $this->getEntityManager()->getRepository(ShortUrl::class)->count()); + } + + private function createDataSet(): int + { + // Create some non-expired short URLs + $this->createShortUrls(5); + $this->createShortUrls(2, [ShortUrlInputFilter::VALID_UNTIL => Chronos::now()->addDays(1)->toAtomString()]); + $this->createShortUrls(3, [ShortUrlInputFilter::MAX_VISITS => 4], visitsPerShortUrl: 2); + + // Create some short URLs with a valid date in the past + $this->createShortUrls(3, [ShortUrlInputFilter::VALID_UNTIL => Chronos::now()->subDays(1)->toAtomString()]); + + // Create some short URLs which reached the max amount of visits + $this->createShortUrls(2, [ShortUrlInputFilter::MAX_VISITS => 3], visitsPerShortUrl: 3); + + // Create some short URLs with a valid date in the past which also reached the max amount of visits + $this->createShortUrls(4, [ + ShortUrlInputFilter::VALID_UNTIL => Chronos::now()->subDays(1)->toAtomString(), + ShortUrlInputFilter::MAX_VISITS => 3, + ], visitsPerShortUrl: 4); + + $this->getEntityManager()->flush(); + + return 5 + 2 + 3 + 3 + 2 + 4; + } + + private function createShortUrls(int $amountOfShortUrls, array $metadata = [], int $visitsPerShortUrl = 0): void + { + for ($i = 0; $i < $amountOfShortUrls; $i++) { + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ + ShortUrlInputFilter::LONG_URL => 'https://shlink.io', + ...$metadata, + ])); + $this->getEntityManager()->persist($shortUrl); + + for ($j = 0; $j < $visitsPerShortUrl; $j++) { + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); + } + } + } +}