Refactored ShortUrlRepository to wrap args into DTOs

This commit is contained in:
Alejandro Celaya 2022-01-17 20:10:41 +01:00
parent 0727c7bdfb
commit 661b07e12f
9 changed files with 251 additions and 119 deletions

View File

@ -18,6 +18,11 @@ final class Ordering
return new self($field, $dir ?? self::DEFAULT_DIR);
}
public static function emptyInstance(): self
{
return self::fromTuple([null, null]);
}
public function orderField(): ?string
{
return $this->field;

View File

@ -11,12 +11,13 @@ use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use function array_column;
@ -26,27 +27,18 @@ use function Functional\contains;
class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
{
/**
* @param string[] $tags
* @return ShortUrl[]
*/
public function findList(
?int $limit = null,
?int $offset = null,
?string $searchTerm = null,
array $tags = [],
?string $tagsMode = null,
?Ordering $orderBy = null,
?DateRange $dateRange = null,
?Specification $spec = null,
): array {
$qb = $this->createListQueryBuilder($searchTerm, $tags, $tagsMode, $dateRange, $spec);
public function findList(ShortUrlsListFiltering $filtering): array
{
$qb = $this->createListQueryBuilder($filtering);
$qb->select('DISTINCT s')
->setMaxResults($limit)
->setFirstResult($offset);
->setMaxResults($filtering->limit())
->setFirstResult($filtering->offset());
// In case the ordering has been specified, the query could be more complex. Process it
if ($orderBy?->hasOrderField()) {
return $this->processOrderByForList($qb, $orderBy);
if ($filtering->orderBy()->hasOrderField()) {
return $this->processOrderByForList($qb, $filtering->orderBy());
}
// With no explicit order by, fallback to dateCreated-DESC
@ -77,30 +69,21 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
return $qb->getQuery()->getResult();
}
public function countList(
?string $searchTerm = null,
array $tags = [],
?string $tagsMode = null,
?DateRange $dateRange = null,
?Specification $spec = null,
): int {
$qb = $this->createListQueryBuilder($searchTerm, $tags, $tagsMode, $dateRange, $spec);
public function countList(ShortUrlsCountFiltering $filtering): int
{
$qb = $this->createListQueryBuilder($filtering);
$qb->select('COUNT(DISTINCT s)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createListQueryBuilder(
?string $searchTerm,
array $tags,
?string $tagsMode,
?DateRange $dateRange,
?Specification $spec,
): QueryBuilder {
private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(ShortUrl::class, 's')
->where('1=1');
$dateRange = $filtering->dateRange();
if ($dateRange?->startDate() !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
$qb->setParameter('startDate', $dateRange->startDate(), ChronosDateTimeType::CHRONOS_DATETIME);
@ -110,6 +93,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$qb->setParameter('endDate', $dateRange->endDate(), ChronosDateTimeType::CHRONOS_DATETIME);
}
$searchTerm = $filtering->searchTerm();
$tags = $filtering->tags();
// Apply search term to every searchable field if not empty
if (! empty($searchTerm)) {
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
@ -131,13 +116,13 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
// Filter by tags if provided
if (! empty($tags)) {
$tagsMode = $tagsMode ?? ShortUrlsParams::TAGS_MODE_ANY;
$tagsMode = $filtering->tagsMode() ?? ShortUrlsParams::TAGS_MODE_ANY;
$tagsMode === ShortUrlsParams::TAGS_MODE_ANY
? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags))
: $this->joinAllTags($qb, $tags);
}
$this->applySpecification($qb, $spec, 's');
$this->applySpecification($qb, $filtering->apiKey()?->spec(), 's');
return $qb;
}

View File

@ -7,34 +7,20 @@ namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
public function findList(
?int $limit = null,
?int $offset = null,
?string $searchTerm = null,
array $tags = [],
?string $tagsMode = null,
?Ordering $orderBy = null,
?DateRange $dateRange = null,
?Specification $spec = null,
): array;
public function findList(ShortUrlsListFiltering $filtering): array;
public function countList(
?string $searchTerm = null,
array $tags = [],
?string $tagsMode = null,
?DateRange $dateRange = null,
?Specification $spec = null,
): int;
public function countList(ShortUrlsCountFiltering $filtering): int;
// TODO Use ShortUrlIdentifier here
public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl;
public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl;

View File

@ -12,10 +12,10 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;

View File

@ -2,11 +2,13 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
namespace Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter;
use Pagerfanta\Adapter\AdapterInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapter implements AdapterInterface
@ -21,25 +23,12 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
public function getSlice(int $offset, int $length): iterable
{
return $this->repository->findList(
$length,
$offset,
$this->params->searchTerm(),
$this->params->tags(),
$this->params->tagsMode(),
$this->params->orderBy(),
$this->params->dateRange(),
$this->apiKey?->spec(),
ShortUrlsListFiltering::fromLimitsAndParams($length, $offset, $this->params, $this->apiKey),
);
}
public function getNbResults(): int
{
return $this->repository->countList(
$this->params->searchTerm(),
$this->params->tags(),
$this->params->tagsMode(),
$this->params->dateRange(),
$this->apiKey?->spec(),
);
return $this->repository->countList(ShortUrlsCountFiltering::fromParams($this->params, $this->apiKey));
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Persistence;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlsCountFiltering
{
public function __construct(
private ?string $searchTerm = null,
private array $tags = [],
private ?string $tagsMode = null,
private ?DateRange $dateRange = null,
private ?ApiKey $apiKey = null,
) {
}
public static function fromParams(ShortUrlsParams $params, ?ApiKey $apiKey): self
{
return new self($params->searchTerm(), $params->tags(), $params->tagsMode(), $params->dateRange(), $apiKey);
}
public function searchTerm(): ?string
{
return $this->searchTerm;
}
public function tags(): array
{
return $this->tags;
}
public function tagsMode(): ?string
{
return $this->tagsMode;
}
public function dateRange(): ?DateRange
{
return $this->dateRange;
}
public function apiKey(): ?ApiKey
{
return $this->apiKey;
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Persistence;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlsListFiltering extends ShortUrlsCountFiltering
{
public function __construct(
private ?int $limit,
private ?int $offset,
private Ordering $orderBy,
?string $searchTerm = null,
array $tags = [],
?string $tagsMode = null,
?DateRange $dateRange = null,
?ApiKey $apiKey = null,
) {
parent::__construct($searchTerm, $tags, $tagsMode, $dateRange, $apiKey);
}
public static function fromLimitsAndParams(int $limit, int $offset, ShortUrlsParams $params, ?ApiKey $apiKey): self
{
return new self(
$limit,
$offset,
$params->orderBy(),
$params->searchTerm(),
$params->tags(),
$params->tagsMode(),
$params->dateRange(),
$apiKey,
);
}
public function offset(): ?int
{
return $this->offset;
}
public function limit(): ?int
{
return $this->limit;
}
public function orderBy(): Ordering
{
return $this->orderBy;
}
}

View File

@ -17,6 +17,8 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
@ -85,7 +87,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
}
$this->getEntityManager()->flush();
self::assertEquals($count, $this->repo->countList());
self::assertEquals($count, $this->repo->countList(new ShortUrlsCountFiltering()));
}
/** @test */
@ -112,44 +114,49 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$result = $this->repo->findList(null, null, 'foo', ['bar']);
$result = $this->repo->findList(
new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'foo', ['bar']),
);
self::assertCount(1, $result);
self::assertEquals(1, $this->repo->countList('foo', ['bar']));
self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering('foo', ['bar'])));
self::assertSame($foo, $result[0]);
$result = $this->repo->findList();
$result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance()));
self::assertCount(3, $result);
$result = $this->repo->findList(2);
$result = $this->repo->findList(new ShortUrlsListFiltering(2, null, Ordering::emptyInstance()));
self::assertCount(2, $result);
$result = $this->repo->findList(2, 1);
$result = $this->repo->findList(new ShortUrlsListFiltering(2, 1, Ordering::emptyInstance()));
self::assertCount(2, $result);
self::assertCount(1, $this->repo->findList(2, 2));
self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(2, 2, Ordering::emptyInstance())));
$result = $this->repo->findList(null, null, null, [], null, Ordering::fromTuple(['visits', 'DESC']));
$result = $this->repo->findList(
new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['visits', 'DESC'])),
);
self::assertCount(3, $result);
self::assertSame($bar, $result[0]);
$result = $this->repo->findList(null, null, null, [], null, null, DateRange::withEndDate(
Chronos::now()->subDays(2),
));
self::assertCount(1, $result);
self::assertEquals(1, $this->repo->countList(null, [], null, DateRange::withEndDate(
Chronos::now()->subDays(2),
)));
self::assertSame($foo2, $result[0]);
self::assertCount(
2,
$this->repo->findList(null, null, null, [], null, null, DateRange::withStartDate(
$result = $this->repo->findList(
new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::withEndDate(
Chronos::now()->subDays(2),
)),
);
self::assertEquals(2, $this->repo->countList(null, [], null, DateRange::withStartDate(
self::assertCount(1, $result);
self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, [], null, DateRange::withEndDate(
Chronos::now()->subDays(2),
)));
))));
self::assertSame($foo2, $result[0]);
self::assertCount(2, $this->repo->findList(
new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::withStartDate(
Chronos::now()->subDays(2),
)),
));
self::assertEquals(2, $this->repo->countList(
new ShortUrlsCountFiltering(null, [], null, DateRange::withStartDate(Chronos::now()->subDays(2))),
));
}
/** @test */
@ -162,7 +169,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$result = $this->repo->findList(null, null, null, [], null, Ordering::fromTuple(['longUrl', 'ASC']));
$result = $this->repo->findList(
new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['longUrl', 'ASC'])),
);
self::assertCount(count($urls), $result);
self::assertEquals('a', $result[0]->getLongUrl());
@ -202,38 +211,86 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
self::assertCount(5, $this->repo->findList(null, null, null, ['foo', 'bar']));
self::assertCount(5, $this->repo->findList(null, null, null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY));
self::assertCount(1, $this->repo->findList(null, null, null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL));
self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar']));
self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY));
self::assertEquals(1, $this->repo->countList(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL));
self::assertCount(4, $this->repo->findList(null, null, null, ['bar', 'baz']));
self::assertCount(4, $this->repo->findList(null, null, null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY));
self::assertCount(2, $this->repo->findList(null, null, null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL));
self::assertEquals(4, $this->repo->countList(null, ['bar', 'baz']));
self::assertEquals(4, $this->repo->countList(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY));
self::assertEquals(2, $this->repo->countList(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL));
self::assertCount(5, $this->repo->findList(null, null, null, ['foo', 'bar', 'baz']));
self::assertCount(5, $this->repo->findList(
new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar']),
));
self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering(
null,
null,
Ordering::emptyInstance(),
null,
['foo', 'bar'],
ShortUrlsParams::TAGS_MODE_ANY,
)));
self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(
null,
null,
Ordering::emptyInstance(),
null,
['foo', 'bar'],
ShortUrlsParams::TAGS_MODE_ALL,
)));
self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'])));
self::assertEquals(5, $this->repo->countList(
new ShortUrlsCountFiltering(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY),
));
self::assertEquals(1, $this->repo->countList(
new ShortUrlsCountFiltering(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL),
));
self::assertCount(4, $this->repo->findList(
new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['bar', 'baz']),
));
self::assertCount(4, $this->repo->findList(new ShortUrlsListFiltering(
null,
null,
Ordering::emptyInstance(),
null,
['bar', 'baz'],
ShortUrlsParams::TAGS_MODE_ANY,
)));
self::assertCount(2, $this->repo->findList(new ShortUrlsListFiltering(
null,
null,
Ordering::emptyInstance(),
null,
['bar', 'baz'],
ShortUrlsParams::TAGS_MODE_ALL,
)));
self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(null, ['bar', 'baz'])));
self::assertEquals(4, $this->repo->countList(
new ShortUrlsCountFiltering(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY),
));
self::assertEquals(2, $this->repo->countList(
new ShortUrlsCountFiltering(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL),
));
self::assertCount(5, $this->repo->findList(
new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar', 'baz']),
));
self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering(
null,
null,
Ordering::emptyInstance(),
null,
['foo', 'bar', 'baz'],
ShortUrlsParams::TAGS_MODE_ANY,
));
self::assertCount(0, $this->repo->findList(
)));
self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering(
null,
null,
Ordering::emptyInstance(),
null,
['foo', 'bar', 'baz'],
ShortUrlsParams::TAGS_MODE_ALL,
)));
self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'])));
self::assertEquals(5, $this->repo->countList(
new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY),
));
self::assertEquals(0, $this->repo->countList(
new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL),
));
self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar', 'baz']));
self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY));
self::assertEquals(0, $this->repo->countList(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL));
}
/** @test */

View File

@ -2,15 +2,17 @@
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
namespace ShlinkioTest\Shlink\Core\ShortUrl\Paginator\Adapter;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapterTest extends TestCase
@ -46,8 +48,9 @@ class ShortUrlRepositoryAdapterTest extends TestCase
$orderBy = $params->orderBy();
$dateRange = $params->dateRange();
$this->repo->findList(10, 5, $searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $orderBy, $dateRange, null)
->shouldBeCalledOnce();
$this->repo->findList(
new ShortUrlsListFiltering(10, 5, $orderBy, $searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange),
)->shouldBeCalledOnce();
$adapter->getSlice(5, 10);
}
@ -71,8 +74,9 @@ class ShortUrlRepositoryAdapterTest extends TestCase
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, $apiKey);
$dateRange = $params->dateRange();
$this->repo->countList($searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange, $apiKey->spec())
->shouldBeCalledOnce();
$this->repo->countList(
new ShortUrlsCountFiltering($searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange, $apiKey),
)->shouldBeCalledOnce();
$adapter->getNbResults();
}