Completed
Push — develop ( 6c30fc...79b883 )
by Alejandro
25s queued 12s
created

VisitRepository::countVisitsByShortCode()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 3
c 0
b 0
f 0
dl 0
loc 7
rs 10
ccs 4
cts 4
cp 1
cc 1
nc 1
nop 3
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Shlinkio\Shlink\Core\Repository;
6
7
use Doctrine\ORM\EntityRepository;
8
use Doctrine\ORM\Query\ResultSetMappingBuilder;
9
use Doctrine\ORM\QueryBuilder;
10
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
11
use Shlinkio\Shlink\Common\Util\DateRange;
12
use Shlinkio\Shlink\Core\Entity\ShortUrl;
13
use Shlinkio\Shlink\Core\Entity\Visit;
14
use Shlinkio\Shlink\Core\Entity\VisitLocation;
15
16
use function preg_replace;
17
18
use const PHP_INT_MAX;
19
20
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
21
{
22
    /**
23
     * @return iterable|Visit[]
24
     */
25 10
    public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
26
    {
27 10
        $qb = $this->getEntityManager()->createQueryBuilder();
28 10
        $qb->select('v')
29 10
           ->from(Visit::class, 'v')
30 10
           ->where($qb->expr()->isNull('v.visitLocation'));
31
32 10
        return $this->findVisitsForQuery($qb, $blockSize);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->findVisitsForQuery($qb, $blockSize) returns the type Generator which is incompatible with the documented return type Shlinkio\Shlink\Core\Entity\Visit[]|iterable.
Loading history...
33
    }
34
35
    /**
36
     * @return iterable|Visit[]
37
     */
38 10
    public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
39
    {
40 10
        $qb = $this->getEntityManager()->createQueryBuilder();
41 10
        $qb->select('v')
42 10
           ->from(Visit::class, 'v')
43 10
           ->join('v.visitLocation', 'vl')
44 10
           ->where($qb->expr()->isNotNull('v.visitLocation'))
45 10
           ->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty'))
46 10
           ->setParameter('isEmpty', true);
47
48 10
        return $this->findVisitsForQuery($qb, $blockSize);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->findVisitsForQuery($qb, $blockSize) returns the type Generator which is incompatible with the documented return type Shlinkio\Shlink\Core\Entity\Visit[]|iterable.
Loading history...
49
    }
50
51 10
    public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
52
    {
53 10
        $qb = $this->getEntityManager()->createQueryBuilder();
54 10
        $qb->select('v')
55 10
           ->from(Visit::class, 'v');
56
57 10
        return $this->findVisitsForQuery($qb, $blockSize);
58
    }
59
60 10
    private function findVisitsForQuery(QueryBuilder $qb, int $blockSize): iterable
61
    {
62 10
        $originalQueryBuilder = $qb->setMaxResults($blockSize)
63 10
                                   ->orderBy('v.id', 'ASC');
64 10
        $lastId = '0';
65
66
        do {
67 10
            $qb = (clone $originalQueryBuilder)->andWhere($qb->expr()->gt('v.id', $lastId));
68 10
            $iterator = $qb->getQuery()->iterate();
69 10
            $resultsFound = false;
70
71
            /** @var Visit $visit */
72 10
            foreach ($iterator as $key => [$visit]) {
73 10
                $resultsFound = true;
74 10
                yield $key => $visit;
75
            }
76
77
            // As the query is ordered by ID, we can take the last one every time in order to exclude the whole list
78 10
            $lastId = isset($visit) ? $visit->getId() : $lastId;
79 10
        } while ($resultsFound);
80
    }
81
82
    /**
83
     * @return Visit[]
84
     */
85 1
    public function findVisitsByShortCode(
86
        string $shortCode,
87
        ?string $domain = null,
88
        ?DateRange $dateRange = null,
89
        ?int $limit = null,
90
        ?int $offset = null
91
    ): array {
92
        /**
93
         * @var QueryBuilder $qb
94
         * @var ShortUrl|int $shortUrl
95
         */
96 1
        [$qb, $shortUrl] = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
97 1
        $qb->select('v.id')
98 1
           ->orderBy('v.id', 'DESC')
99
           // Falling back to values that will behave as no limit/offset, but will workaround MS SQL not allowing
100
           // order on sub-queries without offset
101 1
           ->setMaxResults($limit ?? PHP_INT_MAX)
102 1
           ->setFirstResult($offset ?? 0);
103
104
        // FIXME Crappy way to resolve the params into the query. Best option would be to inject the sub-query with
105
        //       placeholders and then pass params to the main query
106 1
        $shortUrlId = $shortUrl instanceof ShortUrl ? $shortUrl->getId() : $shortUrl;
107 1
        $subQuery = preg_replace('/\?/', $shortUrlId, $qb->getQuery()->getSQL(), 1);
108 1
        if ($dateRange !== null && $dateRange->getStartDate() !== null) {
109 1
            $subQuery = preg_replace(
110 1
                '/\?/',
111 1
                '\'' . $dateRange->getStartDate()->toDateTimeString() . '\'',
112 1
                $subQuery,
113 1
                1,
114
            );
115
        }
116 1
        if ($dateRange !== null && $dateRange->getEndDate() !== null) {
117 1
            $subQuery = preg_replace('/\?/', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\'', $subQuery, 1);
118
        }
119
120
        // A native query builder needs to be used here because DQL and ORM query builders do not accept
121
        // sub-queries at "from" and "join" level.
122
        // If no sub-query is used, then performance drops dramatically while the "offset" grows.
123 1
        $nativeQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
124 1
        $nativeQb->select('v.id AS visit_id', 'v.*', 'vl.*')
125 1
                 ->from('visits', 'v')
126 1
                 ->join('v', '(' . $subQuery . ')', 'sq', $nativeQb->expr()->eq('sq.id_0', 'v.id'))
0 ignored issues
show
Bug introduced by
Are you sure $subQuery of type string[] can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

126
                 ->join('v', '(' . /** @scrutinizer ignore-type */ $subQuery . ')', 'sq', $nativeQb->expr()->eq('sq.id_0', 'v.id'))
Loading history...
127 1
                 ->leftJoin('v', 'visit_locations', 'vl', $nativeQb->expr()->eq('v.visit_location_id', 'vl.id'))
128 1
                 ->orderBy('v.id', 'DESC');
129
130 1
        $rsm = new ResultSetMappingBuilder($this->getEntityManager());
131 1
        $rsm->addRootEntityFromClassMetadata(Visit::class, 'v', ['id' => 'visit_id']);
132 1
        $rsm->addJoinedEntityFromClassMetadata(VisitLocation::class, 'vl', 'v', 'visitLocation', [
133 1
            'id' => 'visit_location_id',
134
        ]);
135
136 1
        $query = $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm);
137
138 1
        return $query->getResult();
139
    }
140
141 1
    public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int
142
    {
143
        /** @var QueryBuilder $qb */
144 1
        [$qb] = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
145 1
        $qb->select('COUNT(v.id)');
146
147 1
        return (int) $qb->getQuery()->getSingleScalarResult();
148
    }
149
150 2
    private function createVisitsByShortCodeQueryBuilder(
151
        string $shortCode,
152
        ?string $domain,
153
        ?DateRange $dateRange
154
    ): array {
155
        /** @var ShortUrlRepositoryInterface $shortUrlRepo */
156 2
        $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
157 2
        $shortUrl = $shortUrlRepo->findOne($shortCode, $domain) ?? -1;
158
159 2
        $qb = $this->getEntityManager()->createQueryBuilder();
160 2
        $qb->from(Visit::class, 'v')
161 2
           ->where($qb->expr()->eq('v.shortUrl', ':shortUrl'))
162 2
           ->setParameter('shortUrl', $shortUrl);
163
164
        // Apply date range filtering
165 2
        if ($dateRange !== null && $dateRange->getStartDate() !== null) {
166 2
            $qb->andWhere($qb->expr()->gte('v.date', ':startDate'))
167 2
               ->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME);
168
        }
169 2
        if ($dateRange !== null && $dateRange->getEndDate() !== null) {
170 2
            $qb->andWhere($qb->expr()->lte('v.date', ':endDate'))
171 2
               ->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME);
172
        }
173
174 2
        return [$qb, $shortUrl];
175
    }
176
}
177