Completed
Pull Request — master (#506)
by Alejandro
13:13
created

ShortUrlRepository   A

Complexity

Total Complexity 20

Size/Duplication

Total Lines 159
Duplicated Lines 0 %

Test Coverage

Coverage 98.57%

Importance

Changes 0
Metric Value
eloc 79
c 0
b 0
f 0
dl 0
loc 159
rs 10
ccs 69
cts 70
cp 0.9857
wmc 20

6 Methods

Rating   Name   Duplication   Size   Complexity  
A processOrderByForList() 0 25 5
A findOneByShortCode() 0 35 4
A findList() 0 26 4
A countList() 0 6 1
A createListQueryBuilder() 0 31 4
A shortCodeIsInUse() 0 19 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Shlinkio\Shlink\Core\Repository;
6
7
use Cake\Chronos\Chronos;
8
use Doctrine\ORM\EntityRepository;
9
use Doctrine\ORM\QueryBuilder;
10
use Shlinkio\Shlink\Core\Entity\ShortUrl;
11
12
use function array_column;
13
use function array_key_exists;
14
use function Functional\contains;
15
use function is_array;
16
use function key;
17
18
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
19
{
20
    /**
21
     * @param string[] $tags
22
     * @param string|array|null $orderBy
23
     * @return ShortUrl[]
24
     */
25 2
    public function findList(
26
        ?int $limit = null,
27
        ?int $offset = null,
28
        ?string $searchTerm = null,
29
        array $tags = [],
30
        $orderBy = null
31
    ): array {
32 2
        $qb = $this->createListQueryBuilder($searchTerm, $tags);
33 2
        $qb->select('DISTINCT s');
34
35
        // Set limit and offset
36 2
        if ($limit !== null) {
37 1
            $qb->setMaxResults($limit);
38
        }
39 2
        if ($offset !== null) {
40 1
            $qb->setFirstResult($offset);
41
        }
42
43
        // In case the ordering has been specified, the query could be more complex. Process it
44 2
        if ($orderBy !== null) {
45 2
            return $this->processOrderByForList($qb, $orderBy);
46
        }
47
48
        // With no order by, order by date and just return the list of ShortUrls
49 1
        $qb->orderBy('s.dateCreated');
50 1
        return $qb->getQuery()->getResult();
51
    }
52
53 2
    private function processOrderByForList(QueryBuilder $qb, $orderBy): array
54
    {
55
        // Map public field names to column names
56
        $fieldNameMap = [
57 2
            'originalUrl' => 'longUrl',
58
            'longUrl' => 'longUrl',
59
            'shortCode' => 'shortCode',
60
            'dateCreated' => 'dateCreated',
61
        ];
62 2
        $fieldName = is_array($orderBy) ? key($orderBy) : $orderBy;
63 2
        $order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
64
65 2
        if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
66 1
            $qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
67 1
               ->leftJoin('s.visits', 'v')
68 1
               ->groupBy('s')
69 1
               ->orderBy('totalVisits', $order);
70
71 1
            return array_column($qb->getQuery()->getResult(), 0);
72
        }
73
74 1
        if (array_key_exists($fieldName, $fieldNameMap)) {
75 1
            $qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
76
        }
77 1
        return $qb->getQuery()->getResult();
78
    }
79
80 1
    public function countList(?string $searchTerm = null, array $tags = []): int
81
    {
82 1
        $qb = $this->createListQueryBuilder($searchTerm, $tags);
83 1
        $qb->select('COUNT(DISTINCT s)');
84
85 1
        return (int) $qb->getQuery()->getSingleScalarResult();
86
    }
87
88 3
    private function createListQueryBuilder(?string $searchTerm = null, array $tags = []): QueryBuilder
89
    {
90 3
        $qb = $this->getEntityManager()->createQueryBuilder();
91 3
        $qb->from(ShortUrl::class, 's');
92 3
        $qb->where('1=1');
93
94
        // Apply search term to every searchable field if not empty
95 3
        if (! empty($searchTerm)) {
96
            // Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
97 1
            if (empty($tags)) {
98
                $qb->leftJoin('s.tags', 't');
99
            }
100
101
            $conditions = [
102 1
                $qb->expr()->like('s.longUrl', ':searchPattern'),
103 1
                $qb->expr()->like('s.shortCode', ':searchPattern'),
104 1
                $qb->expr()->like('t.name', ':searchPattern'),
105
            ];
106
107
            // Unpack and apply search conditions
108 1
            $qb->andWhere($qb->expr()->orX(...$conditions));
109 1
            $qb->setParameter('searchPattern', '%' . $searchTerm . '%');
110
        }
111
112
        // Filter by tags if provided
113 3
        if (! empty($tags)) {
114 1
            $qb->join('s.tags', 't')
115 1
               ->andWhere($qb->expr()->in('t.name', $tags));
116
        }
117
118 3
        return $qb;
119
    }
120
121 1
    public function findOneByShortCode(string $shortCode, ?string $domain = null): ?ShortUrl
122
    {
123
        // When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at
124
        // the bottom
125 1
        $dbPlatform = $this->getEntityManager()->getConnection()->getDatabasePlatform()->getName();
126 1
        $ordering = $dbPlatform === 'postgresql' ? 'ASC' : 'DESC';
127
128
        $dql = <<<DQL
129
            SELECT s
130
              FROM Shlinkio\Shlink\Core\Entity\ShortUrl AS s
131
         LEFT JOIN s.domain AS d
132
             WHERE s.shortCode = :shortCode
133
               AND (s.validSince <= :now OR s.validSince IS NULL)
134
               AND (s.validUntil >= :now OR s.validUntil IS NULL)
135
               AND (s.domain IS NULL OR d.authority = :domain)
136 1
          ORDER BY s.domain {$ordering}
137
DQL;
138
139 1
        $query = $this->getEntityManager()->createQuery($dql);
140 1
        $query->setMaxResults(1)
141 1
              ->setParameters([
142 1
                  'shortCode' => $shortCode,
143 1
                  'now' => Chronos::now(),
144 1
                  'domain' => $domain,
145
              ]);
146
147
        // Since we ordered by domain, we will have first the URL matching provided domain, followed by the one
148
        // with no domain (if any), so it is safe to fetch 1 max result and we will get:
149
        //  * The short URL matching both the short code and the domain, or
150
        //  * The short URL matching the short code but without any domain, or
151
        //  * No short URL at all
152
153
        /** @var ShortUrl|null $shortUrl */
154 1
        $shortUrl = $query->getOneOrNullResult();
155 1
        return $shortUrl !== null && ! $shortUrl->maxVisitsReached() ? $shortUrl : null;
156
    }
157
158 1
    public function shortCodeIsInUse(string $slug, ?string $domain = null): bool
159
    {
160 1
        $qb = $this->getEntityManager()->createQueryBuilder();
161 1
        $qb->select('COUNT(DISTINCT s.id)')
162 1
           ->from(ShortUrl::class, 's')
163 1
           ->where($qb->expr()->isNotNull('s.shortCode'))
164 1
           ->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
165 1
           ->setParameter('slug', $slug);
166
167 1
        if ($domain !== null) {
168 1
            $qb->join('s.domain', 'd')
169 1
               ->andWhere($qb->expr()->eq('d.authority', ':authority'))
170 1
               ->setParameter('authority', $domain);
171
        } else {
172 1
            $qb->andWhere($qb->expr()->isNull('s.domain'));
173
        }
174
175 1
        $result = (int) $qb->getQuery()->getSingleScalarResult();
176 1
        return $result > 0;
177
    }
178
}
179