Use pre-calculated visits counts when listing short URLs

This commit is contained in:
Alejandro Celaya
2024-03-21 08:47:39 +01:00
parent 17d37a062a
commit f678873e9f
8 changed files with 75 additions and 47 deletions

View File

@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
@@ -23,10 +24,10 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use function array_keys; use function array_keys;
use function array_map;
use function array_pad; use function array_pad;
use function explode; use function explode;
use function implode; use function implode;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
use function sprintf; use function sprintf;
class ListShortUrlsCommand extends Command class ListShortUrlsCommand extends Command
@@ -184,10 +185,10 @@ class ListShortUrlsCommand extends Command
): Paginator { ): Paginator {
$shortUrls = $this->shortUrlService->listShortUrls($params); $shortUrls = $this->shortUrlService->listShortUrls($params);
$rows = array_map(function (ShortUrl $shortUrl) use ($columnsMap) { $rows = map([...$shortUrls], function (ShortUrlWithVisitsSummary $shortUrl) use ($columnsMap) {
$rawShortUrl = $this->transformer->transform($shortUrl); $rawShortUrl = $this->transformer->transform($shortUrl);
return array_map(fn (callable $call) => $call($rawShortUrl, $shortUrl), $columnsMap); return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl));
}, [...$shortUrls]); });
ShlinkTable::default($output)->render( ShlinkTable::default($output)->render(
array_keys($columnsMap), array_keys($columnsMap),

View File

@@ -256,7 +256,7 @@ class ShortUrl extends AbstractEntity
return true; return true;
} }
public function toArray(): array public function toArray(?VisitsSummary $precalculatedSummary = null): array
{ {
return [ return [
'shortCode' => $this->shortCode, 'shortCode' => $this->shortCode,
@@ -272,7 +272,7 @@ class ShortUrl extends AbstractEntity
'title' => $this->title, 'title' => $this->title,
'crawlable' => $this->crawlable, 'crawlable' => $this->crawlable,
'forwardQuery' => $this->forwardQuery, 'forwardQuery' => $this->forwardQuery,
'visitsSummary' => VisitsSummary::fromTotalAndNonBots( 'visitsSummary' => $precalculatedSummary ?? VisitsSummary::fromTotalAndNonBots(
count($this->visits), count($this->visits),
count($this->visits->matching( count($this->visits->matching(
Criteria::create()->where(Criteria::expr()->eq('potentialBot', false)), Criteria::create()->where(Criteria::expr()->eq('potentialBot', false)),

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary;
final readonly class ShortUrlWithVisitsSummary
{
private function __construct(public ShortUrl $shortUrl, public VisitsSummary $visitsSummary)
{
}
/**
* @param array{shortUrl: ShortUrl, visitsCount: string|int, nonBotVisitsCount: string|int} $data
*/
public static function fromArray(array $data): self
{
return new self($data['shortUrl'], VisitsSummary::fromTotalAndNonBots(
(int) $data['visitsCount'],
(int) $data['nonBotVisitsCount'],
));
}
public function toArray(): array
{
return $this->shortUrl->toArray($this->visitsSummary);
}
}

View File

@@ -11,13 +11,13 @@ use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapter implements AdapterInterface readonly class ShortUrlRepositoryAdapter implements AdapterInterface
{ {
public function __construct( public function __construct(
private readonly ShortUrlListRepositoryInterface $repository, private ShortUrlListRepositoryInterface $repository,
private readonly ShortUrlsParams $params, private ShortUrlsParams $params,
private readonly ?ApiKey $apiKey, private ?ApiKey $apiKey,
private readonly string $defaultDomain, private string $defaultDomain,
) { ) {
} }

View File

@@ -11,34 +11,39 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use function array_column; use function Shlinkio\Shlink\Core\ArrayUtils\map;
use function sprintf; use function sprintf;
class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface
{ {
/** /**
* @return ShortUrl[] * @return ShortUrlWithVisitsSummary[]
*/ */
public function findList(ShortUrlsListFiltering $filtering): array public function findList(ShortUrlsListFiltering $filtering): array
{ {
$qb = $this->createListQueryBuilder($filtering); $qb = $this->createListQueryBuilder($filtering);
$qb->select('DISTINCT s') $qb->select('DISTINCT s AS shortUrl', 'SUM(v.count) AS visitsCount', 'SUM(v2.count) AS nonBotVisitsCount')
->addSelect('SUM(v.count)')
->leftJoin('s.visitsCounts', 'v')
->leftJoin('s.visitsCounts', 'v2', Join::WITH, $qb->expr()->andX(
$qb->expr()->eq('v.shortUrl', 's'),
$qb->expr()->eq('v.potentialBot', 'false'),
))
->groupBy('s')
->setMaxResults($filtering->limit) ->setMaxResults($filtering->limit)
->setFirstResult($filtering->offset); ->setFirstResult($filtering->offset);
$this->processOrderByForList($qb, $filtering); $this->processOrderByForList($qb, $filtering);
/** @var array{shortUrl: ShortUrl, visitsCount: string, nonBotVisitsCount: string}[] $result */
$result = $qb->getQuery()->getResult(); $result = $qb->getQuery()->getResult();
if (OrderableField::isVisitsField($filtering->orderBy->field ?? '')) { return map($result, static fn (array $s) => ShortUrlWithVisitsSummary::fromArray($s));
return array_column($result, 0);
}
return $result;
} }
private function processOrderByForList(QueryBuilder $qb, ShortUrlsListFiltering $filtering): void private function processOrderByForList(QueryBuilder $qb, ShortUrlsListFiltering $filtering): void
@@ -51,26 +56,12 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
} }
$order = $filtering->orderBy->direction; $order = $filtering->orderBy->direction;
if (OrderableField::isBasicField($fieldName)) { if (OrderableField::isBasicField($fieldName)) {
$qb->orderBy('s.' . $fieldName, $order); $qb->orderBy('s.' . $fieldName, $order);
} elseif (OrderableField::isVisitsField($fieldName)) { } elseif (OrderableField::VISITS->value === $fieldName) {
$leftJoinConditions = [$qb->expr()->eq('v.shortUrl', 's')]; $qb->orderBy('SUM(v.count)', $order);
if ($fieldName === OrderableField::NON_BOT_VISITS->value) { } elseif (OrderableField::NON_BOT_VISITS->value === $fieldName) {
$leftJoinConditions[] = $qb->expr()->eq('v.potentialBot', 'false'); $qb->orderBy('SUM(v2.count)', $order);
}
$qb->addSelect('SUM(v.count)')
->leftJoin('s.visitsCounts', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions))
->groupBy('s')
->orderBy('SUM(v.count)', $order);
// FIXME This query is inefficient.
// Diagnostic: It might need to use a sub-query, as done with the tags list query.
// $qb->addSelect('COUNT(DISTINCT v)')
// ->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions))
// ->groupBy('s')
// ->orderBy('COUNT(DISTINCT v)', $order);
} }
} }

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Repository; namespace Shlinkio\Shlink\Core\ShortUrl\Repository;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
interface ShortUrlListRepositoryInterface interface ShortUrlListRepositoryInterface
{ {
/** /**
* @return ShortUrl[] * @return ShortUrlWithVisitsSummary[]
*/ */
public function findList(ShortUrlsListFiltering $filtering): array; public function findList(ShortUrlsListFiltering $filtering): array;

View File

@@ -6,22 +6,22 @@ namespace Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlListService implements ShortUrlListServiceInterface readonly class ShortUrlListService implements ShortUrlListServiceInterface
{ {
public function __construct( public function __construct(
private readonly ShortUrlListRepositoryInterface $repo, private ShortUrlListRepositoryInterface $repo,
private readonly UrlShortenerOptions $urlShortenerOptions, private UrlShortenerOptions $urlShortenerOptions,
) { ) {
} }
/** /**
* @return ShortUrl[]|Paginator * @return ShortUrlWithVisitsSummary[]|Paginator
*/ */
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator
{ {

View File

@@ -7,7 +7,11 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Transformer;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary;
/**
* @fixme Do not implement DataTransformerInterface, but a separate interface
*/
readonly class ShortUrlDataTransformer implements DataTransformerInterface readonly class ShortUrlDataTransformer implements DataTransformerInterface
{ {
public function __construct(private ShortUrlStringifierInterface $stringifier) public function __construct(private ShortUrlStringifierInterface $stringifier)
@@ -15,13 +19,14 @@ readonly class ShortUrlDataTransformer implements DataTransformerInterface
} }
/** /**
* @param ShortUrl $shortUrl * @param ShortUrlWithVisitsSummary|ShortUrl $data
*/ */
public function transform($shortUrl): array // phpcs:ignore public function transform($data): array // phpcs:ignore
{ {
$shortUrl = $data instanceof ShortUrlWithVisitsSummary ? $data->shortUrl : $data;
return [ return [
'shortUrl' => $this->stringifier->stringify($shortUrl), 'shortUrl' => $this->stringifier->stringify($shortUrl),
...$shortUrl->toArray(), ...$data->toArray(),
]; ];
} }
} }