Completed
Push — develop ( ae060f...1a4eee )
by Alejandro
08:12 queued 08:08
created

VisitRepository::findVisitsByShortCode()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 2
dl 0
loc 9
ccs 3
cts 3
cp 1
rs 10
c 4
b 0
f 0
cc 1
nc 1
nop 5
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\Util\DateRange;
11
use Shlinkio\Shlink\Core\Entity\ShortUrl;
12
use Shlinkio\Shlink\Core\Entity\Visit;
13
use Shlinkio\Shlink\Core\Entity\VisitLocation;
14
15
use function array_column;
16
17
use const PHP_INT_MAX;
18
19
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
20
{
21
    /**
22
     * @return iterable|Visit[]
23
     */
24 10
    public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
25
    {
26 10
        $qb = $this->getEntityManager()->createQueryBuilder();
27 10
        $qb->select('v')
28 10
           ->from(Visit::class, 'v')
29 10
           ->where($qb->expr()->isNull('v.visitLocation'));
30
31 10
        return $this->visitsIterableForQuery($qb, $blockSize);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->visitsIter...rQuery($qb, $blockSize) returns the type Generator which is incompatible with the documented return type Shlinkio\Shlink\Core\Entity\Visit[]|iterable.
Loading history...
32
    }
33
34
    /**
35
     * @return iterable|Visit[]
36
     */
37 10
    public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
38
    {
39 10
        $qb = $this->getEntityManager()->createQueryBuilder();
40 10
        $qb->select('v')
41 10
           ->from(Visit::class, 'v')
42 10
           ->join('v.visitLocation', 'vl')
43 10
           ->where($qb->expr()->isNotNull('v.visitLocation'))
44 10
           ->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty'))
45 10
           ->setParameter('isEmpty', true);
46
47 10
        return $this->visitsIterableForQuery($qb, $blockSize);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->visitsIter...rQuery($qb, $blockSize) returns the type Generator which is incompatible with the documented return type Shlinkio\Shlink\Core\Entity\Visit[]|iterable.
Loading history...
48
    }
49
50 10
    public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
51
    {
52 10
        $qb = $this->getEntityManager()->createQueryBuilder();
53 10
        $qb->select('v')
54 10
           ->from(Visit::class, 'v');
55
56 10
        return $this->visitsIterableForQuery($qb, $blockSize);
57
    }
58
59 10
    private function visitsIterableForQuery(QueryBuilder $qb, int $blockSize): iterable
60
    {
61 10
        $originalQueryBuilder = $qb->setMaxResults($blockSize)
62 10
                                   ->orderBy('v.id', 'ASC');
63 10
        $lastId = '0';
64
65
        do {
66 10
            $qb = (clone $originalQueryBuilder)->andWhere($qb->expr()->gt('v.id', $lastId));
67 10
            $iterator = $qb->getQuery()->iterate();
68 10
            $resultsFound = false;
69
70
            /** @var Visit $visit */
71 10
            foreach ($iterator as $key => [$visit]) {
72 10
                $resultsFound = true;
73 10
                yield $key => $visit;
74
            }
75
76
            // As the query is ordered by ID, we can take the last one every time in order to exclude the whole list
77 10
            $lastId = isset($visit) ? $visit->getId() : $lastId;
78 10
        } while ($resultsFound);
79
    }
80
81
    /**
82
     * @return Visit[]
83
     */
84 1
    public function findVisitsByShortCode(
85
        string $shortCode,
86
        ?string $domain = null,
87
        ?DateRange $dateRange = null,
88
        ?int $limit = null,
89
        ?int $offset = null
90
    ): array {
91 1
        $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
92 1
        return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
93
    }
94
95 1
    public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int
96
    {
97 1
        $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
98 1
        $qb->select('COUNT(v.id)');
99
100 1
        return (int) $qb->getQuery()->getSingleScalarResult();
101
    }
102
103 2
    private function createVisitsByShortCodeQueryBuilder(
104
        string $shortCode,
105
        ?string $domain,
106
        ?DateRange $dateRange
107
    ): QueryBuilder {
108
        /** @var ShortUrlRepositoryInterface $shortUrlRepo */
109 2
        $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
110 2
        $shortUrl = $shortUrlRepo->findOne($shortCode, $domain);
111 2
        $shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1;
112
113
        // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
114
        // Since they are not strictly provided by the caller, it's reasonably safe
115 2
        $qb = $this->getEntityManager()->createQueryBuilder();
116 2
        $qb->from(Visit::class, 'v')
117 2
           ->where($qb->expr()->eq('v.shortUrl', $shortUrlId));
118
119
        // Apply date range filtering
120 2
        $this->applyDatesInline($qb, $dateRange);
121
122 2
        return $qb;
123
    }
124
125 1
    public function findVisitsByTag(
126
        string $tag,
127
        ?DateRange $dateRange = null,
128
        ?int $limit = null,
129
        ?int $offset = null
130
    ): array {
131 1
        $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
132 1
        return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
133
    }
134
135 1
    public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int
136
    {
137 1
        $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
138 1
        $qb->select('COUNT(v.id)');
139
140 1
        return (int) $qb->getQuery()->getSingleScalarResult();
141
    }
142
143 2
    private function createVisitsByTagQueryBuilder(string $tag, ?DateRange $dateRange = null): QueryBuilder
144
    {
145 2
        $qb = $this->getEntityManager()->createQueryBuilder();
146 2
        $qb->select('s.id')
147 2
           ->from(ShortUrl::class, 's')
148 2
           ->join('s.tags', 't')
149 2
           ->where($qb->expr()->eq('t.name', ':tag'))
150 2
           ->setParameter('tag', $tag);
151
152 2
        $shortUrlIds = array_column($qb->getQuery()->getArrayResult(), 'id');
153 2
        $shortUrlIds[] = '-1'; // Add an invalid ID, in case the list is empty
154
155
        // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
156
        // Since they are not strictly provided by the caller, it's reasonably safe
157 2
        $qb2 = $this->getEntityManager()->createQueryBuilder();
158 2
        $qb2->from(Visit::class, 'v')
159 2
            ->where($qb2->expr()->in('v.shortUrl', $shortUrlIds));
160
161
        // Apply date range filtering
162 2
        $this->applyDatesInline($qb2, $dateRange);
163
164 2
        return $qb2;
165
    }
166
167 4
    private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
168
    {
169 4
        if ($dateRange !== null && $dateRange->getStartDate() !== null) {
170 4
            $qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\''));
171
        }
172 4
        if ($dateRange !== null && $dateRange->getEndDate() !== null) {
173 4
            $qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\''));
174
        }
175
    }
176
177 2
    private function resolveVisitsWithNativeQuery(QueryBuilder $qb, ?int $limit, ?int $offset): array
178
    {
179 2
        $qb->select('v.id')
180 2
           ->orderBy('v.id', 'DESC')
181
           // Falling back to values that will behave as no limit/offset, but will workaround MS SQL not allowing
182
           // order on sub-queries without offset
183 2
           ->setMaxResults($limit ?? PHP_INT_MAX)
184 2
           ->setFirstResult($offset ?? 0);
185 2
        $subQuery = $qb->getQuery()->getSQL();
186
187
        // A native query builder needs to be used here because DQL and ORM query builders do not accept
188
        // sub-queries at "from" and "join" level.
189
        // If no sub-query is used, then performance drops dramatically while the "offset" grows.
190 2
        $nativeQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
191 2
        $nativeQb->select('v.id AS visit_id', 'v.*', 'vl.*')
192 2
                 ->from('visits', 'v')
193 2
                 ->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 array 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

193
                 ->join('v', '(' . /** @scrutinizer ignore-type */ $subQuery . ')', 'sq', $nativeQb->expr()->eq('sq.id_0', 'v.id'))
Loading history...
194 2
                 ->leftJoin('v', 'visit_locations', 'vl', $nativeQb->expr()->eq('v.visit_location_id', 'vl.id'))
195 2
                 ->orderBy('v.id', 'DESC');
196
197 2
        $rsm = new ResultSetMappingBuilder($this->getEntityManager());
198 2
        $rsm->addRootEntityFromClassMetadata(Visit::class, 'v', ['id' => 'visit_id']);
199 2
        $rsm->addJoinedEntityFromClassMetadata(VisitLocation::class, 'vl', 'v', 'visitLocation', [
200 2
            'id' => 'visit_location_id',
201
        ]);
202
203 2
        $query = $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm);
204
205 2
        return $query->getResult();
206
    }
207
}
208