ShortUrlRepository.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. <?php
  2. declare(strict_types=1);
  3. namespace Shlinkio\Shlink\Core\Repository;
  4. use Doctrine\ORM\Query\Expr\Join;
  5. use Doctrine\ORM\QueryBuilder;
  6. use Happyr\DoctrineSpecification\EntitySpecificationRepository;
  7. use Happyr\DoctrineSpecification\Specification\Specification;
  8. use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
  9. use Shlinkio\Shlink\Common\Util\DateRange;
  10. use Shlinkio\Shlink\Core\Entity\ShortUrl;
  11. use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
  12. use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
  13. use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
  14. use function array_column;
  15. use function array_key_exists;
  16. use function count;
  17. use function Functional\contains;
  18. class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
  19. {
  20. /**
  21. * @param string[] $tags
  22. * @return ShortUrl[]
  23. */
  24. public function findList(
  25. ?int $limit = null,
  26. ?int $offset = null,
  27. ?string $searchTerm = null,
  28. array $tags = [],
  29. ?ShortUrlsOrdering $orderBy = null,
  30. ?DateRange $dateRange = null,
  31. ?Specification $spec = null
  32. ): array {
  33. $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
  34. $qb->select('DISTINCT s')
  35. ->setMaxResults($limit)
  36. ->setFirstResult($offset);
  37. // In case the ordering has been specified, the query could be more complex. Process it
  38. if ($orderBy !== null && $orderBy->hasOrderField()) {
  39. return $this->processOrderByForList($qb, $orderBy);
  40. }
  41. // With no order by, order by date and just return the list of ShortUrls
  42. $qb->orderBy('s.dateCreated');
  43. return $qb->getQuery()->getResult();
  44. }
  45. private function processOrderByForList(QueryBuilder $qb, ShortUrlsOrdering $orderBy): array
  46. {
  47. $fieldName = $orderBy->orderField();
  48. $order = $orderBy->orderDirection();
  49. if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
  50. $qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
  51. ->leftJoin('s.visits', 'v')
  52. ->groupBy('s')
  53. ->orderBy('totalVisits', $order);
  54. return array_column($qb->getQuery()->getResult(), 0);
  55. }
  56. // Map public field names to column names
  57. $fieldNameMap = [
  58. 'originalUrl' => 'longUrl',
  59. 'longUrl' => 'longUrl',
  60. 'shortCode' => 'shortCode',
  61. 'dateCreated' => 'dateCreated',
  62. ];
  63. if (array_key_exists($fieldName, $fieldNameMap)) {
  64. $qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
  65. }
  66. return $qb->getQuery()->getResult();
  67. }
  68. public function countList(
  69. ?string $searchTerm = null,
  70. array $tags = [],
  71. ?DateRange $dateRange = null,
  72. ?Specification $spec = null
  73. ): int {
  74. $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
  75. $qb->select('COUNT(DISTINCT s)');
  76. return (int) $qb->getQuery()->getSingleScalarResult();
  77. }
  78. private function createListQueryBuilder(
  79. ?string $searchTerm,
  80. array $tags,
  81. ?DateRange $dateRange,
  82. ?Specification $spec
  83. ): QueryBuilder {
  84. $qb = $this->getEntityManager()->createQueryBuilder();
  85. $qb->from(ShortUrl::class, 's')
  86. ->where('1=1');
  87. if ($dateRange !== null && $dateRange->getStartDate() !== null) {
  88. $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
  89. $qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME);
  90. }
  91. if ($dateRange !== null && $dateRange->getEndDate() !== null) {
  92. $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
  93. $qb->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME);
  94. }
  95. // Apply search term to every searchable field if not empty
  96. if (! empty($searchTerm)) {
  97. // Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
  98. if (empty($tags)) {
  99. $qb->leftJoin('s.tags', 't');
  100. }
  101. // Apply search conditions
  102. $qb->leftJoin('s.domain', 'd')
  103. ->andWhere($qb->expr()->orX(
  104. $qb->expr()->like('s.longUrl', ':searchPattern'),
  105. $qb->expr()->like('s.shortCode', ':searchPattern'),
  106. $qb->expr()->like('s.title', ':searchPattern'),
  107. $qb->expr()->like('t.name', ':searchPattern'),
  108. $qb->expr()->like('d.authority', ':searchPattern'),
  109. ))
  110. ->setParameter('searchPattern', '%' . $searchTerm . '%');
  111. }
  112. // Filter by tags if provided
  113. if (! empty($tags)) {
  114. $qb->join('s.tags', 't')
  115. ->andWhere($qb->expr()->in('t.name', $tags));
  116. }
  117. $this->applySpecification($qb, $spec, 's');
  118. return $qb;
  119. }
  120. public function findOneWithDomainFallback(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. $dbPlatform = $this->getEntityManager()->getConnection()->getDatabasePlatform()->getName();
  125. $ordering = $dbPlatform === 'postgresql' ? 'ASC' : 'DESC';
  126. $dql = <<<DQL
  127. SELECT s
  128. FROM Shlinkio\Shlink\Core\Entity\ShortUrl AS s
  129. LEFT JOIN s.domain AS d
  130. WHERE s.shortCode = :shortCode
  131. AND (s.domain IS NULL OR d.authority = :domain)
  132. ORDER BY s.domain {$ordering}
  133. DQL;
  134. $query = $this->getEntityManager()->createQuery($dql);
  135. $query->setMaxResults(1)
  136. ->setParameters([
  137. 'shortCode' => $shortCode,
  138. 'domain' => $domain,
  139. ]);
  140. // Since we ordered by domain, we will have first the URL matching provided domain, followed by the one
  141. // with no domain (if any), so it is safe to fetch 1 max result and we will get:
  142. // * The short URL matching both the short code and the domain, or
  143. // * The short URL matching the short code but without any domain, or
  144. // * No short URL at all
  145. return $query->getOneOrNullResult();
  146. }
  147. public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl
  148. {
  149. $qb = $this->createFindOneQueryBuilder($shortCode, $domain, $spec);
  150. $qb->select('s');
  151. return $qb->getQuery()->getOneOrNullResult();
  152. }
  153. public function shortCodeIsInUse(string $slug, ?string $domain = null, ?Specification $spec = null): bool
  154. {
  155. $qb = $this->createFindOneQueryBuilder($slug, $domain, $spec);
  156. $qb->select('COUNT(DISTINCT s.id)');
  157. return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
  158. }
  159. private function createFindOneQueryBuilder(string $slug, ?string $domain, ?Specification $spec): QueryBuilder
  160. {
  161. $qb = $this->getEntityManager()->createQueryBuilder();
  162. $qb->from(ShortUrl::class, 's')
  163. ->where($qb->expr()->isNotNull('s.shortCode'))
  164. ->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
  165. ->setParameter('slug', $slug)
  166. ->setMaxResults(1);
  167. $this->whereDomainIs($qb, $domain);
  168. $this->applySpecification($qb, $spec, 's');
  169. return $qb;
  170. }
  171. public function findOneMatching(ShortUrlMeta $meta): ?ShortUrl
  172. {
  173. $qb = $this->getEntityManager()->createQueryBuilder();
  174. $qb->select('s')
  175. ->from(ShortUrl::class, 's')
  176. ->where($qb->expr()->eq('s.longUrl', ':longUrl'))
  177. ->setParameter('longUrl', $meta->getLongUrl())
  178. ->setMaxResults(1)
  179. ->orderBy('s.id');
  180. if ($meta->hasCustomSlug()) {
  181. $qb->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
  182. ->setParameter('slug', $meta->getCustomSlug());
  183. }
  184. if ($meta->hasMaxVisits()) {
  185. $qb->andWhere($qb->expr()->eq('s.maxVisits', ':maxVisits'))
  186. ->setParameter('maxVisits', $meta->getMaxVisits());
  187. }
  188. if ($meta->hasValidSince()) {
  189. $qb->andWhere($qb->expr()->eq('s.validSince', ':validSince'))
  190. ->setParameter('validSince', $meta->getValidSince(), ChronosDateTimeType::CHRONOS_DATETIME);
  191. }
  192. if ($meta->hasValidUntil()) {
  193. $qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil'))
  194. ->setParameter('validUntil', $meta->getValidUntil(), ChronosDateTimeType::CHRONOS_DATETIME);
  195. }
  196. if ($meta->hasDomain()) {
  197. $qb->join('s.domain', 'd')
  198. ->andWhere($qb->expr()->eq('d.authority', ':domain'))
  199. ->setParameter('domain', $meta->getDomain());
  200. }
  201. $apiKey = $meta->getApiKey();
  202. if ($apiKey !== null) {
  203. $this->applySpecification($qb, $apiKey->spec(), 's');
  204. }
  205. $tags = $meta->getTags();
  206. $tagsAmount = count($tags);
  207. if ($tagsAmount === 0) {
  208. return $qb->getQuery()->getOneOrNullResult();
  209. }
  210. foreach ($tags as $index => $tag) {
  211. $alias = 't_' . $index;
  212. $qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index)
  213. ->setParameter('tag' . $index, $tag);
  214. }
  215. // If tags where provided, we need an extra join to see the amount of tags that every short URL has, so that we
  216. // can discard those that also have more tags, making sure only those fully matching are included.
  217. $qb->join('s.tags', 't')
  218. ->groupBy('s')
  219. ->having($qb->expr()->eq('COUNT(t.id)', ':tagsAmount'))
  220. ->setParameter('tagsAmount', $tagsAmount);
  221. return $qb->getQuery()->getOneOrNullResult();
  222. }
  223. public function importedUrlExists(ImportedShlinkUrl $url): bool
  224. {
  225. $qb = $this->getEntityManager()->createQueryBuilder();
  226. $qb->select('COUNT(DISTINCT s.id)')
  227. ->from(ShortUrl::class, 's')
  228. ->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode'))
  229. ->setParameter('shortCode', $url->shortCode())
  230. ->andWhere($qb->expr()->eq('s.importSource', ':importSource'))
  231. ->setParameter('importSource', $url->source())
  232. ->setMaxResults(1);
  233. $this->whereDomainIs($qb, $url->domain());
  234. $result = (int) $qb->getQuery()->getSingleScalarResult();
  235. return $result > 0;
  236. }
  237. private function whereDomainIs(QueryBuilder $qb, ?string $domain): void
  238. {
  239. if ($domain !== null) {
  240. $qb->join('s.domain', 'd')
  241. ->andWhere($qb->expr()->eq('d.authority', ':authority'))
  242. ->setParameter('authority', $domain);
  243. } else {
  244. $qb->andWhere($qb->expr()->isNull('s.domain'));
  245. }
  246. }
  247. }