mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-17 15:59:56 +03:00
Merge pull request #1316 from acelaya-forks/feature/tags-ordering
Feature/tags ordering
This commit is contained in:
commit
397bbe2655
24 changed files with 202 additions and 128 deletions
|
@ -48,7 +48,7 @@
|
|||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^1.0",
|
||||
"ramsey/uuid": "^4.2",
|
||||
"shlinkio/shlink-common": "^4.3",
|
||||
"shlinkio/shlink-common": "dev-main#cbcff58 as 4.4",
|
||||
"shlinkio/shlink-config": "^1.5",
|
||||
"shlinkio/shlink-event-dispatcher": "^2.3",
|
||||
"shlinkio/shlink-importer": "^2.5",
|
||||
|
|
|
@ -54,6 +54,19 @@
|
|||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "orderBy",
|
||||
"in": "query",
|
||||
"description": "To determine how to order the results.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tag-ASC",
|
||||
"tag-DESC"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
|
@ -11,7 +11,6 @@ use Shlinkio\Shlink\Common\Paginator\Paginator;
|
|||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
|
||||
|
@ -135,7 +134,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
|||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
|
||||
ShortUrlsOrdering::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
|
||||
];
|
||||
|
|
|
@ -263,10 +263,10 @@ class ListShortUrlsCommandTest extends TestCase
|
|||
public function provideOrderBy(): iterable
|
||||
{
|
||||
yield [[], null];
|
||||
yield [['--order-by' => 'foo'], 'foo'];
|
||||
yield [['--order-by' => 'foo,ASC'], 'foo-ASC'];
|
||||
yield [['--order-by' => 'bar,DESC'], 'bar-DESC'];
|
||||
yield [['--order-by' => 'baz-DESC'], 'baz-DESC'];
|
||||
yield [['--order-by' => 'visits'], 'visits'];
|
||||
yield [['--order-by' => 'longUrl,ASC'], 'longUrl-ASC'];
|
||||
yield [['--order-by' => 'shortCode,DESC'], 'shortCode-DESC'];
|
||||
yield [['--order-by' => 'title-DESC'], 'title-DESC'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
@ -42,6 +42,9 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
|
|||
$e->invalidElements = $invalidData;
|
||||
$e->additional = ['invalidElements' => array_keys($invalidData)];
|
||||
|
||||
// TODO Expose reasons for the validation to fail
|
||||
// $e->additional = ['invalidElements' => array_keys($invalidData), 'reasons' => $invalidData];
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
|
|
35
module/Core/src/Model/Ordering.php
Normal file
35
module/Core/src/Model/Ordering.php
Normal file
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
final class Ordering
|
||||
{
|
||||
private const DEFAULT_DIR = 'ASC';
|
||||
|
||||
private function __construct(private ?string $field, private string $dir)
|
||||
{
|
||||
}
|
||||
|
||||
public static function fromTuple(array $props): self
|
||||
{
|
||||
[$field, $dir] = $props;
|
||||
return new self($field, $dir ?? self::DEFAULT_DIR);
|
||||
}
|
||||
|
||||
public function orderField(): ?string
|
||||
{
|
||||
return $this->field;
|
||||
}
|
||||
|
||||
public function orderDirection(): string
|
||||
{
|
||||
return $this->dir;
|
||||
}
|
||||
|
||||
public function hasOrderField(): bool
|
||||
{
|
||||
return $this->field !== null;
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
|
||||
use function array_pad;
|
||||
use function explode;
|
||||
|
||||
final class ShortUrlsOrdering
|
||||
{
|
||||
public const ORDER_BY = 'orderBy';
|
||||
private const DEFAULT_ORDER_DIRECTION = 'ASC';
|
||||
|
||||
private ?string $orderField = null;
|
||||
private string $orderDirection = self::DEFAULT_ORDER_DIRECTION;
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function fromRawData(array $query): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->validateAndInit($query);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function validateAndInit(array $data): void
|
||||
{
|
||||
$orderBy = $data[self::ORDER_BY] ?? null;
|
||||
if ($orderBy === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$field, $dir] = array_pad(explode('-', $orderBy), 2, null);
|
||||
$this->orderField = $field;
|
||||
$this->orderDirection = $dir ?? self::DEFAULT_ORDER_DIRECTION;
|
||||
}
|
||||
|
||||
public function orderField(): ?string
|
||||
{
|
||||
return $this->orderField;
|
||||
}
|
||||
|
||||
public function orderDirection(): string
|
||||
{
|
||||
return $this->orderDirection;
|
||||
}
|
||||
|
||||
public function hasOrderField(): bool
|
||||
{
|
||||
return $this->orderField !== null;
|
||||
}
|
||||
}
|
|
@ -13,6 +13,7 @@ use function Shlinkio\Shlink\Core\parseDateField;
|
|||
|
||||
final class ShortUrlsParams
|
||||
{
|
||||
public const ORDERABLE_FIELDS = ['longUrl', 'shortCode', 'dateCreated', 'title', 'visits'];
|
||||
public const DEFAULT_ITEMS_PER_PAGE = 10;
|
||||
public const TAGS_MODE_ANY = 'any';
|
||||
public const TAGS_MODE_ALL = 'all';
|
||||
|
@ -23,7 +24,7 @@ final class ShortUrlsParams
|
|||
private array $tags;
|
||||
/** @var self::TAGS_MODE_ANY|self::TAGS_MODE_ALL */
|
||||
private string $tagsMode = self::TAGS_MODE_ANY;
|
||||
private ShortUrlsOrdering $orderBy;
|
||||
private Ordering $orderBy;
|
||||
private ?DateRange $dateRange;
|
||||
|
||||
private function __construct()
|
||||
|
@ -63,7 +64,7 @@ final class ShortUrlsParams
|
|||
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
|
||||
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
|
||||
);
|
||||
$this->orderBy = ShortUrlsOrdering::fromRawData($query);
|
||||
$this->orderBy = Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY));
|
||||
$this->itemsPerPage = (int) (
|
||||
$inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE
|
||||
);
|
||||
|
@ -90,7 +91,7 @@ final class ShortUrlsParams
|
|||
return $this->tags;
|
||||
}
|
||||
|
||||
public function orderBy(): ShortUrlsOrdering
|
||||
public function orderBy(): Ordering
|
||||
{
|
||||
return $this->orderBy;
|
||||
}
|
||||
|
|
|
@ -13,9 +13,9 @@ 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\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
|
||||
|
@ -35,7 +35,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
|
|||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
?string $tagsMode = null,
|
||||
?ShortUrlsOrdering $orderBy = null,
|
||||
?Ordering $orderBy = null,
|
||||
?DateRange $dateRange = null,
|
||||
?Specification $spec = null,
|
||||
): array {
|
||||
|
@ -53,13 +53,14 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
|
|||
return $qb->orderBy('s.dateCreated', 'DESC')->getQuery()->getResult();
|
||||
}
|
||||
|
||||
private function processOrderByForList(QueryBuilder $qb, ShortUrlsOrdering $orderBy): array
|
||||
private function processOrderByForList(QueryBuilder $qb, Ordering $orderBy): array
|
||||
{
|
||||
$fieldName = $orderBy->orderField();
|
||||
$order = $orderBy->orderDirection();
|
||||
|
||||
if ($fieldName === 'visits') {
|
||||
// FIXME This query is inefficient. Debug it.
|
||||
// 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) AS totalVisits')
|
||||
->leftJoin('s.visits', 'v')
|
||||
->groupBy('s')
|
||||
|
|
|
@ -9,9 +9,9 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterfa
|
|||
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\Model\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
|
||||
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
|
@ -22,7 +22,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat
|
|||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
?string $tagsMode = null,
|
||||
?ShortUrlsOrdering $orderBy = null,
|
||||
?Ordering $orderBy = null,
|
||||
?DateRange $dateRange = null,
|
||||
?Specification $spec = null,
|
||||
): array;
|
||||
|
|
|
@ -50,7 +50,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
|
|||
$conn = $this->getEntityManager()->getConnection();
|
||||
$subQb = $this->createQueryBuilder('t');
|
||||
$subQb->select('t.id', 't.name')
|
||||
->orderBy('t.name', 'ASC') // TODO Make dynamic
|
||||
->orderBy('t.name', $filtering?->orderBy()?->orderDirection() ?? 'ASC') // TODO Make filed dynamic
|
||||
->setMaxResults($filtering?->limit() ?? PHP_INT_MAX)
|
||||
->setFirstResult($filtering?->offset() ?? 0);
|
||||
|
||||
|
@ -96,7 +96,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
|
|||
->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
|
||||
->orderBy('t.name_1', $filtering?->orderBy()?->orderDirection() ?? 'ASC'); // TODO Make field dynamic
|
||||
|
||||
// Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates
|
||||
$apiKey?->mapRoles(fn (string $roleName, array $meta) => match ($roleName) {
|
||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Model;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\Ordering;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
final class TagsListFiltering
|
||||
|
@ -12,10 +13,16 @@ final class TagsListFiltering
|
|||
private ?int $limit = null,
|
||||
private ?int $offset = null,
|
||||
private ?string $searchTerm = null,
|
||||
private ?Ordering $orderBy = null,
|
||||
private ?ApiKey $apiKey = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromRangeAndParams(int $limit, int $offset, TagsParams $params, ?ApiKey $apiKey): self
|
||||
{
|
||||
return new self($limit, $offset, $params->searchTerm(), $params->orderBy(), $apiKey);
|
||||
}
|
||||
|
||||
public function limit(): ?int
|
||||
{
|
||||
return $this->limit;
|
||||
|
@ -31,6 +38,11 @@ final class TagsListFiltering
|
|||
return $this->searchTerm;
|
||||
}
|
||||
|
||||
public function orderBy(): ?Ordering
|
||||
{
|
||||
return $this->orderBy;
|
||||
}
|
||||
|
||||
public function apiKey(): ?ApiKey
|
||||
{
|
||||
return $this->apiKey;
|
||||
|
|
|
@ -5,11 +5,19 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Tag\Model;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\AbstractInfinitePaginableListParams;
|
||||
use Shlinkio\Shlink\Core\Model\Ordering;
|
||||
|
||||
use function Shlinkio\Shlink\Common\parseOrderBy;
|
||||
|
||||
final class TagsParams extends AbstractInfinitePaginableListParams
|
||||
{
|
||||
private function __construct(private ?string $searchTerm, ?int $page, ?int $itemsPerPage)
|
||||
{
|
||||
private function __construct(
|
||||
private ?string $searchTerm,
|
||||
private Ordering $orderBy,
|
||||
private bool $withStats,
|
||||
?int $page,
|
||||
?int $itemsPerPage,
|
||||
) {
|
||||
parent::__construct($page, $itemsPerPage);
|
||||
}
|
||||
|
||||
|
@ -17,6 +25,8 @@ final class TagsParams extends AbstractInfinitePaginableListParams
|
|||
{
|
||||
return new self(
|
||||
$query['searchTerm'] ?? null,
|
||||
Ordering::fromTuple(isset($query['orderBy']) ? parseOrderBy($query['orderBy']) : [null, null]),
|
||||
($query['withStats'] ?? null) === 'true',
|
||||
isset($query['page']) ? (int) $query['page'] : null,
|
||||
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
|
||||
);
|
||||
|
@ -26,4 +36,14 @@ final class TagsParams extends AbstractInfinitePaginableListParams
|
|||
{
|
||||
return $this->searchTerm;
|
||||
}
|
||||
|
||||
public function orderBy(): Ordering
|
||||
{
|
||||
return $this->orderBy;
|
||||
}
|
||||
|
||||
public function withStats(): bool
|
||||
{
|
||||
return $this->withStats;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ class TagsInfoPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
|||
public function getSlice(int $offset, int $length): iterable
|
||||
{
|
||||
return $this->repo->findTagsWithInfo(
|
||||
new TagsListFiltering($length, $offset, $this->params->searchTerm(), $this->apiKey),
|
||||
TagsListFiltering::fromRangeAndParams($length, $offset, $this->params, $this->apiKey),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,10 @@ class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
|||
{
|
||||
$conditions = [
|
||||
new WithApiKeySpecsEnsuringJoin($this->apiKey),
|
||||
Spec::orderBy('name'),
|
||||
Spec::orderBy(
|
||||
'name', // Ordering by other fields makes no sense here
|
||||
$this->params->orderBy()->orderDirection(),
|
||||
),
|
||||
Spec::limit($length),
|
||||
Spec::offset($offset),
|
||||
];
|
||||
|
|
|
@ -21,6 +21,7 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
|||
public const END_DATE = 'endDate';
|
||||
public const ITEMS_PER_PAGE = 'itemsPerPage';
|
||||
public const TAGS_MODE = 'tagsMode';
|
||||
public const ORDER_BY = 'orderBy';
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
|
@ -46,5 +47,7 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
|||
'strict' => InArray::COMPARE_STRICT,
|
||||
]));
|
||||
$this->add($tagsMode);
|
||||
|
||||
$this->add($this->createOrderByInput(self::ORDER_BY, ShortUrlsParams::ORDERABLE_FIELDS));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,9 +11,9 @@ use Shlinkio\Shlink\Common\Util\DateRange;
|
|||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Ordering;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
|
@ -128,9 +128,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
|||
|
||||
self::assertCount(1, $this->repo->findList(2, 2));
|
||||
|
||||
$result = $this->repo->findList(null, null, null, [], null, ShortUrlsOrdering::fromRawData([
|
||||
'orderBy' => 'visits-DESC',
|
||||
]));
|
||||
$result = $this->repo->findList(null, null, null, [], null, Ordering::fromTuple(['visits', 'DESC']));
|
||||
self::assertCount(3, $result);
|
||||
self::assertSame($bar, $result[0]);
|
||||
|
||||
|
@ -164,9 +162,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
|||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$result = $this->repo->findList(null, null, null, [], null, ShortUrlsOrdering::fromRawData([
|
||||
'orderBy' => 'longUrl-ASC',
|
||||
]));
|
||||
$result = $this->repo->findList(null, null, null, [], null, Ordering::fromTuple(['longUrl', 'ASC']));
|
||||
|
||||
self::assertCount(count($urls), $result);
|
||||
self::assertEquals('a', $result[0]->getLongUrl());
|
||||
|
|
|
@ -8,6 +8,7 @@ use Shlinkio\Shlink\Core\Entity\Domain;
|
|||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Ordering;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
|
@ -86,7 +87,7 @@ class TagRepositoryTest extends DatabaseTestCase
|
|||
|
||||
public function provideFilters(): iterable
|
||||
{
|
||||
$noFiltersAsserts = static function (array $result, array $tagNames): void {
|
||||
$defaultAsserts = static function (array $result, array $tagNames): void {
|
||||
/** @var TagInfo[] $result */
|
||||
self::assertCount(4, $result);
|
||||
self::assertEquals(0, $result[0]->shortUrlsCount());
|
||||
|
@ -106,8 +107,8 @@ class TagRepositoryTest extends DatabaseTestCase
|
|||
self::assertEquals($tagNames[0], $result[3]->tag()->__toString());
|
||||
};
|
||||
|
||||
yield 'no filter' => [null, $noFiltersAsserts];
|
||||
yield 'empty filter' => [new TagsListFiltering(), $noFiltersAsserts];
|
||||
yield 'no filter' => [null, $defaultAsserts];
|
||||
yield 'empty filter' => [new TagsListFiltering(), $defaultAsserts];
|
||||
yield 'limit' => [new TagsListFiltering(2), static function (array $result, array $tagNames): void {
|
||||
/** @var TagInfo[] $result */
|
||||
self::assertCount(2, $result);
|
||||
|
@ -154,6 +155,32 @@ class TagRepositoryTest extends DatabaseTestCase
|
|||
self::assertEquals($tagNames[2], $result[1]->tag()->__toString());
|
||||
},
|
||||
];
|
||||
yield 'ASC ordering' => [
|
||||
new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'ASC'])),
|
||||
$defaultAsserts,
|
||||
];
|
||||
yield 'DESC ordering' => [
|
||||
new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'DESC'])),
|
||||
static function (array $result, array $tagNames): void {
|
||||
/** @var TagInfo[] $result */
|
||||
self::assertCount(4, $result);
|
||||
self::assertEquals(0, $result[3]->shortUrlsCount());
|
||||
self::assertEquals(0, $result[3]->visitsCount());
|
||||
self::assertEquals($tagNames[3], $result[3]->tag()->__toString());
|
||||
|
||||
self::assertEquals(1, $result[2]->shortUrlsCount());
|
||||
self::assertEquals(3, $result[2]->visitsCount());
|
||||
self::assertEquals($tagNames[1], $result[2]->tag()->__toString());
|
||||
|
||||
self::assertEquals(1, $result[1]->shortUrlsCount());
|
||||
self::assertEquals(3, $result[1]->visitsCount());
|
||||
self::assertEquals($tagNames[2], $result[1]->tag()->__toString());
|
||||
|
||||
self::assertEquals(2, $result[0]->shortUrlsCount());
|
||||
self::assertEquals(4, $result[0]->visitsCount());
|
||||
self::assertEquals($tagNames[0], $result[0]->tag()->__toString());
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
@ -10,6 +10,8 @@ use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
|||
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
||||
|
||||
use function Functional\map;
|
||||
|
||||
class TagsPaginatorAdapterTest extends DatabaseTestCase
|
||||
{
|
||||
private TagRepository $repo;
|
||||
|
@ -25,9 +27,10 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase
|
|||
*/
|
||||
public function expectedListOfTagsIsReturned(
|
||||
?string $searchTerm,
|
||||
?string $orderBy,
|
||||
int $offset,
|
||||
int $length,
|
||||
int $expectedSliceSize,
|
||||
array $expectedTags,
|
||||
int $expectedTotalCount,
|
||||
): void {
|
||||
$names = ['foo', 'bar', 'baz', 'another'];
|
||||
|
@ -36,22 +39,31 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase
|
|||
}
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$adapter = new TagsPaginatorAdapter($this->repo, TagsParams::fromRawData(['searchTerm' => $searchTerm]), null);
|
||||
$adapter = new TagsPaginatorAdapter($this->repo, TagsParams::fromRawData([
|
||||
'searchTerm' => $searchTerm,
|
||||
'orderBy' => $orderBy,
|
||||
]), null);
|
||||
|
||||
self::assertCount($expectedSliceSize, $adapter->getSlice($offset, $length));
|
||||
$tagNames = map($adapter->getSlice($offset, $length), static fn (Tag $tag) => $tag->__toString());
|
||||
|
||||
self::assertEquals($expectedTags, $tagNames);
|
||||
self::assertEquals($expectedTotalCount, $adapter->getNbResults());
|
||||
}
|
||||
|
||||
public function provideFilters(): iterable
|
||||
{
|
||||
yield [null, 0, 10, 4, 4];
|
||||
yield [null, 2, 10, 2, 4];
|
||||
yield [null, 1, 3, 3, 4];
|
||||
yield [null, 3, 3, 1, 4];
|
||||
yield [null, 0, 2, 2, 4];
|
||||
yield ['ba', 0, 10, 2, 2];
|
||||
yield ['ba', 0, 1, 1, 2];
|
||||
yield ['foo', 0, 10, 1, 1];
|
||||
yield ['a', 0, 10, 3, 3];
|
||||
yield [null, null, 0, 10, ['another', 'bar', 'baz', 'foo'], 4];
|
||||
yield [null, null, 2, 10, ['baz', 'foo'], 4];
|
||||
yield [null, null, 1, 3, ['bar', 'baz', 'foo'], 4];
|
||||
yield [null, null, 3, 3, ['foo'], 4];
|
||||
yield [null, null, 0, 2, ['another', 'bar'], 4];
|
||||
yield ['ba', null, 0, 10, ['bar', 'baz'], 2];
|
||||
yield ['ba', null, 0, 1, ['bar'], 2];
|
||||
yield ['foo', null, 0, 10, ['foo'], 1];
|
||||
yield ['a', null, 0, 10, ['another', 'bar', 'baz'], 3];
|
||||
yield [null, 'tag-DESC', 0, 10, ['foo', 'baz', 'bar', 'another'], 4];
|
||||
yield [null, 'tag-ASC', 0, 10, ['another', 'bar', 'baz', 'foo'], 4];
|
||||
yield [null, 'tag-DESC', 0, 2, ['foo', 'baz'], 4];
|
||||
yield ['ba', 'tag-DESC', 0, 1, ['baz'], 2];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -82,11 +82,11 @@ class ShortUrlRepositoryAdapterTest extends TestCase
|
|||
yield ['search'];
|
||||
yield ['search', []];
|
||||
yield ['search', ['foo', 'bar']];
|
||||
yield ['search', ['foo', 'bar'], null, null, 'order'];
|
||||
yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'order'];
|
||||
yield ['search', ['foo', 'bar'], null, Chronos::now()->toAtomString(), 'order'];
|
||||
yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), Chronos::now()->toAtomString(), 'order'];
|
||||
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'order'];
|
||||
yield ['search', ['foo', 'bar'], null, null, 'longUrl'];
|
||||
yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'longUrl'];
|
||||
yield ['search', ['foo', 'bar'], null, Chronos::now()->toAtomString(), 'longUrl'];
|
||||
yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), Chronos::now()->toAtomString(), 'longUrl'];
|
||||
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'longUrl'];
|
||||
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString()];
|
||||
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString(), Chronos::now()->toAtomString()];
|
||||
}
|
||||
|
|
|
@ -83,26 +83,26 @@ class TagServiceTest extends TestCase
|
|||
{
|
||||
yield 'no API key, no filter' => [
|
||||
null,
|
||||
TagsParams::fromRawData([]),
|
||||
new TagsListFiltering(2, 0, null, null),
|
||||
$params = TagsParams::fromRawData([]),
|
||||
TagsListFiltering::fromRangeAndParams(2, 0, $params, null),
|
||||
1,
|
||||
];
|
||||
yield 'admin API key, no filter' => [
|
||||
$apiKey = ApiKey::create(),
|
||||
TagsParams::fromRawData([]),
|
||||
new TagsListFiltering(2, 0, null, $apiKey),
|
||||
$params = TagsParams::fromRawData([]),
|
||||
TagsListFiltering::fromRangeAndParams(2, 0, $params, $apiKey),
|
||||
1,
|
||||
];
|
||||
yield 'no API key, search term' => [
|
||||
null,
|
||||
TagsParams::fromRawData(['searchTerm' => $searchTerm = 'foobar']),
|
||||
new TagsListFiltering(2, 0, $searchTerm, null),
|
||||
$params = TagsParams::fromRawData(['searchTerm' => 'foobar']),
|
||||
TagsListFiltering::fromRangeAndParams(2, 0, $params, null),
|
||||
1,
|
||||
];
|
||||
yield 'admin API key, limits' => [
|
||||
$apiKey = ApiKey::create(),
|
||||
TagsParams::fromRawData(['page' => 1, 'itemsPerPage' => 1]),
|
||||
new TagsListFiltering(1, 0, null, $apiKey),
|
||||
$params = TagsParams::fromRawData(['page' => 1, 'itemsPerPage' => 1]),
|
||||
TagsListFiltering::fromRangeAndParams(1, 0, $params, $apiKey),
|
||||
0,
|
||||
];
|
||||
}
|
||||
|
|
|
@ -30,11 +30,10 @@ class ListTagsAction extends AbstractRestAction
|
|||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$query = $request->getQueryParams();
|
||||
$withStats = ($query['withStats'] ?? null) === 'true';
|
||||
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
|
||||
$params = TagsParams::fromRawData($query);
|
||||
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
|
||||
|
||||
if (! $withStats) {
|
||||
if (! $params->withStats()) {
|
||||
return new JsonResponse([
|
||||
'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)),
|
||||
]);
|
||||
|
|
|
@ -242,20 +242,29 @@ class ListShortUrlsTest extends ApiTestCase
|
|||
];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function errorIsReturnedWhenProvidingInvalidValues(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInvalidFiltering
|
||||
*/
|
||||
public function errorIsReturnedWhenProvidingInvalidValues(array $query, array $expectedInvalidElements): void
|
||||
{
|
||||
$query = ['tagsMode' => 'invalid'];
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query]);
|
||||
$respPayload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
self::assertEquals(400, $resp->getStatusCode());
|
||||
self::assertEquals([
|
||||
'invalidElements' => ['tagsMode'],
|
||||
'invalidElements' => $expectedInvalidElements,
|
||||
'title' => 'Invalid data',
|
||||
'type' => 'INVALID_ARGUMENT',
|
||||
'status' => 400,
|
||||
'detail' => 'Provided data is not valid',
|
||||
], $respPayload);
|
||||
}
|
||||
|
||||
public function provideInvalidFiltering(): iterable
|
||||
{
|
||||
yield [['tagsMode' => 'invalid'], ['tagsMode']];
|
||||
yield [['orderBy' => 'invalid'], ['orderBy']];
|
||||
yield [['orderBy' => 'invalid', 'tagsMode' => 'invalid'], ['tagsMode', 'orderBy']];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl;
|
|||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use Pagerfanta\Adapter\ArrayAdapter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
|
@ -52,7 +52,8 @@ class ListShortUrlsActionTest extends TestCase
|
|||
?string $endDate = null,
|
||||
): void {
|
||||
$apiKey = ApiKey::create();
|
||||
$request = (new ServerRequest())->withQueryParams($query)->withAttribute(ApiKey::class, $apiKey);
|
||||
$request = ServerRequestFactory::fromGlobals()->withQueryParams($query)
|
||||
->withAttribute(ApiKey::class, $apiKey);
|
||||
$listShortUrls = $this->service->listShortUrls(ShortUrlsParams::fromRawData([
|
||||
'page' => $expectedPage,
|
||||
'searchTerm' => $expectedSearchTerm,
|
||||
|
@ -81,10 +82,10 @@ class ListShortUrlsActionTest extends TestCase
|
|||
yield [['page' => '8'], 8, null, [], null];
|
||||
yield [['searchTerm' => $searchTerm = 'foo'], 1, $searchTerm, [], null];
|
||||
yield [['tags' => $tags = ['foo','bar']], 1, null, $tags, null];
|
||||
yield [['orderBy' => $orderBy = 'something'], 1, null, [], $orderBy];
|
||||
yield [['orderBy' => $orderBy = 'longUrl'], 1, null, [], $orderBy];
|
||||
yield [[
|
||||
'page' => '2',
|
||||
'orderBy' => $orderBy = 'something',
|
||||
'orderBy' => $orderBy = 'visits',
|
||||
'tags' => $tags = ['one', 'two'],
|
||||
], 2, null, $tags, $orderBy];
|
||||
yield [
|
||||
|
|
Loading…
Add table
Reference in a new issue