Completed
Push — master ( a81ac8...05e307 )
by Alejandro
25s queued 13s
created

ShortUrlRepository::slugIsInUse()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 19
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 2

Importance

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