diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index 4e9eafac..83582f8b 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -15,7 +15,6 @@ use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; -use Shlinkio\Shlink\Rest\ApiKey\Spec\WithInlinedApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Functional\map; @@ -46,93 +45,99 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $orderDir = $filtering?->orderBy?->direction; $orderMainQuery = $orderField !== null && OrderableField::isAggregateField($orderField); - $subQb = $this->createQueryBuilder('t'); - $subQb->select('t.id', 't.name'); + $conn = $this->getEntityManager()->getConnection(); + $tagsSubQb = $conn->createQueryBuilder(); + $tagsSubQb->select('t.id', 't.name')->from('tags', 't'); if (! $orderMainQuery) { - $subQb->orderBy('t.name', $orderDir ?? 'ASC') - ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) - ->setFirstResult($filtering?->offset ?? 0); + $tagsSubQb + ->orderBy('t.name', $orderDir ?? 'ASC') + ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) + ->setFirstResult($filtering?->offset ?? 0); // TODO Check if applying limit/offset ot visits sub-queries is needed with large amounts of tags } - $conn = $this->getEntityManager()->getConnection(); - $buildVisitsSubQuery = static function (bool $excludeBots, string $aggregateAlias) use ($conn) { - $visitsSubQuery = $conn->createQueryBuilder(); - $commonJoinCondition = $visitsSubQuery->expr()->eq('v.short_url_id', 's.id'); + $buildVisitsSubQb = static function (bool $excludeBots, string $aggregateAlias) use ($conn) { + $visitsSubQb = $conn->createQueryBuilder(); + $commonJoinCondition = $visitsSubQb->expr()->eq('v.short_url_id', 's.id'); $visitsJoin = ! $excludeBots ? $commonJoinCondition - : $visitsSubQuery->expr()->and( + : $visitsSubQb->expr()->and( $commonJoinCondition, - $visitsSubQuery->expr()->eq('v.potential_bot', $conn->quote('0')), + $visitsSubQb->expr()->eq('v.potential_bot', $conn->quote('0')), ); - return $visitsSubQuery + return $visitsSubQb ->select('st.tag_id AS tag_id', 'COUNT(DISTINCT v.id) AS ' . $aggregateAlias) ->from('visits', 'v') ->join('v', 'short_urls', 's', $visitsJoin) // @phpstan-ignore-line - ->join('s', 'short_urls_in_tags', 'st', $visitsSubQuery->expr()->eq('st.short_url_id', 's.id')) + ->join('s', 'short_urls_in_tags', 'st', $visitsSubQb->expr()->eq('st.short_url_id', 's.id')) ->groupBy('st.tag_id'); }; - $allVisitsSubQuery = $buildVisitsSubQuery(false, 'visits'); - $nonBotVisitsSubQuery = $buildVisitsSubQuery(true, 'non_bot_visits'); + $allVisitsSubQb = $buildVisitsSubQb(false, 'visits'); + $nonBotVisitsSubQb = $buildVisitsSubQb(true, 'non_bot_visits'); $searchTerm = $filtering?->searchTerm; if ($searchTerm !== null) { - $subQb->andWhere($subQb->expr()->like('t.name', $conn->quote('%' . $searchTerm . '%'))); + $tagsSubQb->andWhere($tagsSubQb->expr()->like('t.name', $conn->quote('%' . $searchTerm . '%'))); // TODO Check if applying this to all sub-queries makes it faster or slower } $apiKey = $filtering?->apiKey; - $applyApiKeyToNativeQuery = static fn (?ApiKey $apiKey, NativeQueryBuilder $nativeQueryBuilder) => + $applyApiKeyToNativeQb = static fn (?ApiKey $apiKey, NativeQueryBuilder $qb) => $apiKey?->mapRoles(static fn (Role $role, array $meta) => match ($role) { - Role::DOMAIN_SPECIFIC => $nativeQueryBuilder->andWhere( - $nativeQueryBuilder->expr()->eq('s.domain_id', $conn->quote(Role::domainIdFromMeta($meta))), + Role::DOMAIN_SPECIFIC => $qb->andWhere( + $qb->expr()->eq('s.domain_id', $conn->quote(Role::domainIdFromMeta($meta))), ), - Role::AUTHORED_SHORT_URLS => $nativeQueryBuilder->andWhere( - $nativeQueryBuilder->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())), + Role::AUTHORED_SHORT_URLS => $qb->andWhere( + $qb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())), ), }); // Apply API key specification to all sub-queries - $this->applySpecification($subQb, new WithInlinedApiKeySpecsEnsuringJoin($apiKey), 't'); - $applyApiKeyToNativeQuery($apiKey, $allVisitsSubQuery); - $applyApiKeyToNativeQuery($apiKey, $nonBotVisitsSubQuery); + if ($apiKey && ! $apiKey->isAdmin()) { + $tagsSubQb + ->join('t', 'short_urls_in_tags', 'st', $tagsSubQb->expr()->eq('st.tag_id', 't.id')) + ->join('st', 'short_urls', 's', $tagsSubQb->expr()->eq('st.short_url_id', 's.id')); + } + $applyApiKeyToNativeQb($apiKey, $tagsSubQb); + $applyApiKeyToNativeQb($apiKey, $allVisitsSubQb); + $applyApiKeyToNativeQb($apiKey, $nonBotVisitsSubQb); // A native query builder needs to be used here, because DQL and ORM query builders do not support // sub-queries at "from" and "join" level. // If no sub-query is used, the whole list is loaded even with pagination, making it very inefficient. - $nativeQb = $conn->createQueryBuilder(); - $nativeQb + $mainQb = $conn->createQueryBuilder(); + $mainQb ->select( - 't.id_0 AS id', - 't.name_1 AS name', + 't.id AS id', + 't.name AS name', 'COALESCE(v.visits, 0) AS visits', // COALESCE required for postgres to properly order 'COALESCE(v2.non_bot_visits, 0) AS non_bot_visits', // COALESCE required for postgres to properly order 'COUNT(DISTINCT s.id) AS short_urls_count', ) - ->from('(' . $subQb->getQuery()->getSQL() . ')', 't') // @phpstan-ignore-line - ->leftJoin('t', 'short_urls_in_tags', 'st', $nativeQb->expr()->eq('t.id_0', 'st.tag_id')) - ->leftJoin('st', 'short_urls', 's', $nativeQb->expr()->eq('s.id', 'st.short_url_id')) - ->leftJoin('t', '(' . $allVisitsSubQuery->getSQL() . ')', 'v', $nativeQb->expr()->eq('t.id_0', 'v.tag_id')) - ->leftJoin('t', '(' . $nonBotVisitsSubQuery->getSQL() . ')', 'v2', $nativeQb->expr()->eq( - 't.id_0', + ->from('(' . $tagsSubQb->getSQL() . ')', 't') + ->leftJoin('t', 'short_urls_in_tags', 'st', $mainQb->expr()->eq('t.id', 'st.tag_id')) + ->leftJoin('st', 'short_urls', 's', $mainQb->expr()->eq('s.id', 'st.short_url_id')) + ->leftJoin('t', '(' . $allVisitsSubQb->getSQL() . ')', 'v', $mainQb->expr()->eq('t.id', 'v.tag_id')) + ->leftJoin('t', '(' . $nonBotVisitsSubQb->getSQL() . ')', 'v2', $mainQb->expr()->eq( + 't.id', 'v2.tag_id', )) - ->groupBy('t.id_0', 't.name_1', 'v.visits', 'v2.non_bot_visits'); + ->groupBy('t.id', 't.name', 'v.visits', 'v2.non_bot_visits'); // Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates - $applyApiKeyToNativeQuery($apiKey, $nativeQb); + $applyApiKeyToNativeQb($apiKey, $mainQb); if ($orderMainQuery) { - $nativeQb + $mainQb ->orderBy(OrderableField::toSnakeCaseValidField($orderField), $orderDir ?? 'ASC') ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) ->setFirstResult($filtering?->offset ?? 0); } // Add ordering by tag name, as a fallback in case of same amount, or as default ordering - $nativeQb->addOrderBy('t.name_1', $orderMainQuery || $orderDir === null ? 'ASC' : $orderDir); + $mainQb->addOrderBy('t.name', $orderMainQuery || $orderDir === null ? 'ASC' : $orderDir); $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addScalarResult('name', 'tag'); @@ -141,7 +146,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); return map( - $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(), + $this->getEntityManager()->createNativeQuery($mainQb->getSQL(), $rsm)->getResult(), TagInfo::fromRawData(...), ); }