diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php index f65be80a..8e06f5c0 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php @@ -38,4 +38,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) ->addJoinColumn('short_url_id', 'id', onDelete: 'CASCADE') ->build(); + + $builder->addUniqueConstraint(['short_url_id', 'potential_bot', 'slot_id'], 'UQ_slot_per_short_url'); }; diff --git a/module/Core/migrations/Version20240318084804.php b/module/Core/migrations/Version20240318084804.php index 6b906107..a9501a2a 100644 --- a/module/Core/migrations/Version20240318084804.php +++ b/module/Core/migrations/Version20240318084804.php @@ -30,30 +30,29 @@ final class Version20240318084804 extends AbstractMigration $botsCount = $visitsQb->setParameter('potential_bot', '1')->executeQuery()->fetchOne(); $nonBotsCount = $visitsQb->setParameter('potential_bot', '0')->executeQuery()->fetchOne(); - $this->connection->createQueryBuilder() - ->insert('short_url_visits_counts') - ->values([ - 'short_url_id' => ':short_url_id', - 'count' => ':count', - 'potential_bot' => '1', - ]) - ->setParameters([ - 'short_url_id' => $shortUrlId, - 'count' => $botsCount, - ]) - ->executeStatement(); - $this->connection->createQueryBuilder() - ->insert('short_url_visits_counts') - ->values([ - 'short_url_id' => ':short_url_id', - 'count' => ':count', - 'potential_bot' => '0', - ]) - ->setParameters([ - 'short_url_id' => $shortUrlId, - 'count' => $nonBotsCount, - ]) - ->executeStatement(); + if ($botsCount > 0) { + $this->insertCount($shortUrlId, $botsCount, potentialBot: true); + } + if ($nonBotsCount > 0) { + $this->insertCount($shortUrlId, $nonBotsCount, potentialBot: false); + } } } + + private function insertCount(string $shortUrlId, int $count, bool $potentialBot): void + { + $this->connection->createQueryBuilder() + ->insert('short_url_visits_counts') + ->values([ + 'short_url_id' => ':short_url_id', + 'count' => ':count', + 'potential_bot' => ':potential_bot', + ]) + ->setParameters([ + 'short_url_id' => $shortUrlId, + 'count' => $count, + 'potential_bot' => $potentialBot ? '1' : '0', + ]) + ->executeStatement(); + } } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php index 8244cde4..79bdb526 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php @@ -14,13 +14,13 @@ final readonly class ShortUrlWithVisitsSummary } /** - * @param array{shortUrl: ShortUrl, visitsCount: string|int, nonBotVisitsCount: string|int} $data + * @param array{shortUrl: ShortUrl, visits: string|int, nonBotVisits: string|int} $data */ public static function fromArray(array $data): self { return new self($data['shortUrl'], VisitsSummary::fromTotalAndNonBots( - (int) $data['visitsCount'], - (int) $data['nonBotVisitsCount'], + (int) $data['visits'], + (int) $data['nonBotVisits'], )); } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 32bcdc28..0c0c3df3 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -15,6 +15,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; 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; @@ -27,21 +28,33 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh */ public function findList(ShortUrlsListFiltering $filtering): array { + $buildVisitsSubQuery = function (string $alias, bool $excludingBots): string { + $vqb = $this->getEntityManager()->createQueryBuilder(); + $vqb->select('SUM(' . $alias . '.count)') + ->from(ShortUrlVisitsCount::class, $alias) + ->where($vqb->expr()->eq($alias . '.shortUrl', 's')); + + if ($excludingBots) { + $vqb->andWhere($vqb->expr()->eq($alias . '.potentialBot', ':potentialBot')); + } + + return $vqb->getDQL(); + }; + $qb = $this->createListQueryBuilder($filtering); - $qb->select('DISTINCT s AS shortUrl', 'SUM(v.count) AS visitsCount', 'SUM(v2.count) AS nonBotVisitsCount') - ->addSelect('SUM(v.count)') - ->leftJoin('s.visitsCounts', 'v') - ->leftJoin('s.visitsCounts', 'v2', Join::WITH, $qb->expr()->andX( - $qb->expr()->eq('v.shortUrl', 's'), - $qb->expr()->eq('v.potentialBot', 'false'), - )) - ->groupBy('s') + $qb->select( + 'DISTINCT s AS shortUrl', + '(' . $buildVisitsSubQuery('v', excludingBots: false) . ') AS ' . OrderableField::VISITS->value, + '(' . $buildVisitsSubQuery('v2', excludingBots: true) . ') AS ' . OrderableField::NON_BOT_VISITS->value, + ) ->setMaxResults($filtering->limit) - ->setFirstResult($filtering->offset); + ->setFirstResult($filtering->offset) + // This param is used in one of the sub-queries, but needs to set in the parent query + ->setParameter('potentialBot', 0); $this->processOrderByForList($qb, $filtering); - /** @var array{shortUrl: ShortUrl, visitsCount: string, nonBotVisitsCount: string}[] $result */ + /** @var array{shortUrl: ShortUrl, visits: string, nonBotVisits: string}[] $result */ $result = $qb->getQuery()->getResult(); return map($result, static fn (array $s) => ShortUrlWithVisitsSummary::fromArray($s)); } @@ -54,8 +67,8 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh match (true) { // With no explicit order by, fallback to dateCreated-DESC $fieldName === null => $qb->orderBy('s.dateCreated', 'DESC'), - $fieldName === OrderableField::VISITS->value => $qb->orderBy('SUM(v.count)', $order), - $fieldName === OrderableField::NON_BOT_VISITS->value => $qb->orderBy('SUM(v2.count)', $order), + $fieldName === OrderableField::VISITS->value, + $fieldName === OrderableField::NON_BOT_VISITS->value => $qb->orderBy($fieldName, $order), default => $qb->orderBy('s.' . $fieldName, $order), }; }