VisitRepository::visitsIterableForQuery()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 20
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 4

Importance

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

183
                 ->join('v', '(' . /** @scrutinizer ignore-type */ $subQuery . ')', 'sq', $nativeQb->expr()->eq('sq.id_0', 'v.id'))
Loading history...
184 3
                 ->leftJoin('v', 'visit_locations', 'vl', $nativeQb->expr()->eq('v.visit_location_id', 'vl.id'))
185 3
                 ->orderBy('v.id', 'DESC');
186
187 3
        $rsm = new ResultSetMappingBuilder($this->getEntityManager());
188 3
        $rsm->addRootEntityFromClassMetadata(Visit::class, 'v', ['id' => 'visit_id']);
189 3
        $rsm->addJoinedEntityFromClassMetadata(VisitLocation::class, 'vl', 'v', 'visitLocation', [
190 3
            'id' => 'visit_location_id',
191
        ]);
192
193 3
        $query = $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm);
194
195 3
        return $query->getResult();
196
    }
197
}
198