Fixed performance issues on list tags endpoint when requesting it with stats

This commit is contained in:
Alejandro Celaya 2022-01-06 19:01:00 +01:00
parent 2b0567b368
commit 107c09604a
2 changed files with 45 additions and 18 deletions

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Core\Entity\Tag;
@ -15,6 +17,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Functional\map;
use const PHP_INT_MAX;
class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface
{
public function deleteByName(array $names): int
@ -35,29 +39,52 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
*/
public function findTagsWithInfo(?TagsListFiltering $filtering = null): array
{
$qb = $this->createQueryBuilder('t');
$qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount')
->leftJoin('t.shortUrls', 's')
->leftJoin('s.visits', 'v')
->groupBy('t')
->orderBy('t.name', 'ASC')
->setMaxResults($filtering?->limit())
->setFirstResult($filtering?->offset());
$subQb = $this->createQueryBuilder('t');
$subQb->select('t.id', 't.name')
->orderBy('t.name', 'ASC') // TODO Make dynamic
->setMaxResults($filtering?->limit() ?? PHP_INT_MAX)
->setFirstResult($filtering?->offset() ?? 0);
$searchTerm = $filtering?->searchTerm();
if ($searchTerm !== null) {
$qb->andWhere($qb->expr()->like('t.name', ':searchPattern'))
->setParameter('searchPattern', '%' . $searchTerm . '%');
// FIXME This value cannot be added via params, so it needs to be sanitized
$subQb->andWhere($subQb->expr()->like('t.name', '\'%' . $searchTerm . '%\''));
}
$apiKey = $filtering?->apiKey();
if ($apiKey !== null) {
$this->applySpecification($qb, $apiKey->spec(false, 'shortUrls'), 't');
}
$subQuery = $subQb->getQuery()->getSQL();
// A native query builder needs to be used here because DQL and ORM query builders do not accept
// 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 = $this->getEntityManager()->getConnection()->createQueryBuilder();
$nativeQb
->select(
't.id_0 AS id',
't.name_1 AS name',
'COUNT(DISTINCT s.id) AS short_urls_count',
'COUNT(DISTINCT v.id) AS visits_count',
)
->from('(' . $subQuery . ')', 't')
->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('st', 'visits', 'v', $nativeQb->expr()->eq('s.id', 'v.short_url_id'))
->groupBy('t.id_0', 't.name_1')
->orderBy('t.name_1', 'ASC'); // TODO Make dynamic
$rsm = new ResultSetMappingBuilder($this->getEntityManager());
$rsm->addRootEntityFromClassMetadata(Tag::class, 't');
$rsm->addScalarResult('short_urls_count', 'shortUrlsCount');
$rsm->addScalarResult('visits_count', 'visitsCount');
// TODO Apply API key cond to main query
// $apiKey = $filtering?->apiKey();
// if ($apiKey !== null) {
// $this->applySpecification($nativeQb, $apiKey->spec(false, 'shortUrls'), 't');
// }
return map(
$qb->getQuery()->getResult(),
static fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
$this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(),
static fn (array $row) => new TagInfo($row[0], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
);
}

View file

@ -54,7 +54,7 @@ class TagRepositoryTest extends DatabaseTestCase
/**
* @test
* @dataProvider provideFilterings
* @dataProvider provideFilters
*/
public function properTagsInfoIsReturned(?TagsListFiltering $filtering, callable $asserts): void
{
@ -84,7 +84,7 @@ class TagRepositoryTest extends DatabaseTestCase
$asserts($result, $names);
}
public function provideFilterings(): iterable
public function provideFilters(): iterable
{
$noFiltersAsserts = static function (array $result, array $tagNames): void {
/** @var TagInfo[] $result */