diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index e38980c4..4cb7ff42 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -5,10 +5,16 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Entity\VisitLocation; + +use function preg_replace; + +use const PHP_INT_MAX; class VisitRepository extends EntityRepository implements VisitRepositoryInterface { @@ -82,19 +88,60 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa ?int $limit = null, ?int $offset = null ): array { - $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); - $qb->select('v', 'vl') - ->leftJoin('v.visitLocation', 'vl') + /** + * @var QueryBuilder $qb + * @var ShortUrl|int $shortUrl + */ + [$qb, $shortUrl] = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); + $qb->select('v.id') ->orderBy('v.id', 'DESC') - ->setMaxResults($limit) - ->setFirstResult($offset); + // Falling back to values that will behave as no limit/offset, but will workaround MS SQL not allowing + // order on sub-queries without offset + ->setMaxResults($limit ?? PHP_INT_MAX) + ->setFirstResult($offset ?? 0); - return $qb->getQuery()->getResult(); + // FIXME Crappy way to resolve the params into the query. Best option would be to inject the sub-query with + // placeholders and then pass params to the main query + $shortUrlId = $shortUrl instanceof ShortUrl ? $shortUrl->getId() : $shortUrl; + $subQuery = $qb->getQuery()->getSQL(); + $subQuery = preg_replace('/\?/', $shortUrlId, $subQuery, 1); + if ($dateRange !== null && $dateRange->getStartDate() !== null) { + $subQuery = preg_replace( + '/\?/', + '\'' . $dateRange->getStartDate()->toDateTimeString() . '\'', + $subQuery, + 1, + ); + } + if ($dateRange !== null && $dateRange->getEndDate() !== null) { + $subQuery = preg_replace('/\?/', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\'', $subQuery, 1); + } + + // A native query builder needs to be used here because DQL and ORM query builders do not accept + // sub-queries at "from" and "join" level. + // If no sub-query is used, then the performance drops dramatically while the "offset" grows. + $nativeQb = $this->getEntityManager()->getConnection()->createQueryBuilder(); + $nativeQb->select('v.*', 'vl.*') + ->from('visits', 'v') + ->join('v', '(' . $subQuery . ')', 'o', $nativeQb->expr()->eq('o.id_0', 'v.id')) + ->leftJoin('v', 'visit_locations', 'vl', $nativeQb->expr()->eq('v.visit_location_id', 'vl.id')) + ->orderBy('v.id', 'DESC'); + + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); + $rsm->addRootEntityFromClassMetadata(Visit::class, 'v'); + $rsm->addJoinedEntityFromClassMetadata(VisitLocation::class, 'vl', 'v', 'visitLocation', [ + 'id' => 'visit_location_id', + ]); + + $query = $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm); + + return $query->getResult(); } public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int { - $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); + /** @var QueryBuilder $qb */ + [$qb] = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); $qb->select('COUNT(v.id)'); return (int) $qb->getQuery()->getSingleScalarResult(); @@ -104,7 +151,7 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa string $shortCode, ?string $domain, ?DateRange $dateRange - ): QueryBuilder { + ): array { /** @var ShortUrlRepositoryInterface $shortUrlRepo */ $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); $shortUrl = $shortUrlRepo->findOne($shortCode, $domain) ?? -1; @@ -124,6 +171,6 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa ->setParameter('endDate', $dateRange->getEndDate()); } - return $qb; + return [$qb, $shortUrl]; } }