mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 04:03:12 +08:00
Merge pull request #2492 from acelaya-forks/feature/exclude-tags
Allow listing short URLs which DO NOT include certain tags
This commit is contained in:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
* [#2327](https://github.com/shlinkio/shlink/issues/2327) Allow filtering short URLs lists by those not including certain tags.
|
||||
|
||||
Now, the `GET /short-urls` endpoint accepts two new params: `excludeTags`, which is an array of strings with the tags that should not be included, and `excludeTagsMode`, which accepts the values `any` and `all`, and determines if short URLs should be filtered out if they contain any of the excluded tags, or all the excluded tags.
|
||||
|
||||
Additionally, the `short-url:list` command also supports the same feature via `--exclude-tag` option, which requires a value and can be provided multiple times, and `--exclude-tags-all`, which does not expect a value and determines if the mode should be `all`, or `any`.
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [4.5.3] - 2025-10-10
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"name": "searchTerm",
|
||||
"in": "query",
|
||||
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields. (Since v1.3.0)",
|
||||
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
@@ -40,7 +40,7 @@
|
||||
{
|
||||
"name": "tags[]",
|
||||
"in": "query",
|
||||
"description": "A list of tags used to filter the result set. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
|
||||
"description": "A list of tags used to filter the result set. Only short URLs **with** these tags will be returned.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "array",
|
||||
@@ -52,7 +52,29 @@
|
||||
{
|
||||
"name": "tagsMode",
|
||||
"in": "query",
|
||||
"description": "Tells how the filtering by tags should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. It's ignored if no tags are provided, and defaults to \"any\" if not provided.",
|
||||
"description": "Tells how the filtering by `tags` should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. Defaults to \"any\".<br />It's ignored if `tags` is not provided.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["any", "all"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeTags[]",
|
||||
"in": "query",
|
||||
"description": "A list of tags used to filter the result set. Only short URLs **without** these tags will be returned.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeTagsMode",
|
||||
"in": "query",
|
||||
"description": "Tells how the filtering by `excludeTags` should work, returning short URLs not containing \"any\" of the tags, or not containing \"all\" the tags. Defaults to \"any\".<br />It's ignored if `excludeTags` is not provided.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
|
||||
@@ -27,6 +27,7 @@ use function array_keys;
|
||||
use function array_pad;
|
||||
use function explode;
|
||||
use function implode;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||
use function sprintf;
|
||||
|
||||
@@ -72,15 +73,31 @@ class ListShortUrlsCommand extends Command
|
||||
)
|
||||
->addOption(
|
||||
'tags',
|
||||
't',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'A comma-separated list of tags to filter results.',
|
||||
mode: InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
description: '[DEPRECATED] Use --tag instead',
|
||||
)
|
||||
->addOption(
|
||||
'including-all-tags',
|
||||
'i',
|
||||
InputOption::VALUE_NONE,
|
||||
'If tags is provided, returns only short URLs having ALL tags.',
|
||||
'tag',
|
||||
't',
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'A list of tags that short URLs need to include.',
|
||||
)
|
||||
->addOption('including-all-tags', 'i', InputOption::VALUE_NONE, '[DEPRECATED] Use --tags-all instead')
|
||||
->addOption(
|
||||
'tags-all',
|
||||
mode: InputOption::VALUE_NONE,
|
||||
description: 'If --tags is provided, returns only short URLs including ALL of them',
|
||||
)
|
||||
->addOption(
|
||||
'exclude-tag',
|
||||
'et',
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'A list of tags that short URLs should not have.',
|
||||
)
|
||||
->addOption(
|
||||
'exclude-tags-all',
|
||||
mode: InputOption::VALUE_NONE,
|
||||
description: 'If --exclude-tag is provided, returns only short URLs not including ANY of them',
|
||||
)
|
||||
->addOption(
|
||||
'exclude-max-visits-reached',
|
||||
@@ -136,9 +153,17 @@ class ListShortUrlsCommand extends Command
|
||||
$page = (int) $input->getOption('page');
|
||||
$searchTerm = $input->getOption('search-term');
|
||||
$domain = $input->getOption('domain');
|
||||
$tags = $input->getOption('tags');
|
||||
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
|
||||
$tags = ! empty($tags) ? explode(',', $tags) : [];
|
||||
|
||||
// FIXME DEPRECATED Remove support for comma-separated tags in next major release
|
||||
$tags = [...$input->getOption('tag'), ...$input->getOption('tags')];
|
||||
$tags = flatten(map($tags, static fn (string $tag) => explode(',', $tag)));
|
||||
$tagsMode = $input->getOption('tags-all') === true || $input->getOption('including-all-tags') === true
|
||||
? TagsMode::ALL->value
|
||||
: TagsMode::ANY->value;
|
||||
|
||||
$excludeTags = $input->getOption('exclude-tag');
|
||||
$excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
|
||||
|
||||
$all = $input->getOption('all');
|
||||
$startDate = $this->startDateOption->get($input, $output);
|
||||
$endDate = $this->endDateOption->get($input, $output);
|
||||
@@ -150,6 +175,8 @@ class ListShortUrlsCommand extends Command
|
||||
ShortUrlsParamsInputFilter::DOMAIN => $domain,
|
||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_TAGS => $excludeTags,
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode,
|
||||
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
|
||||
|
||||
@@ -87,6 +87,15 @@ class ListShortUrlsTest extends CliTestCase
|
||||
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
+------------+---------------+----------------------+--------------------------------------- Page 1 of 1 -------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'exclude tags' => [['--exclude-tag=foo'], <<<OUTPUT
|
||||
+--------------------+-------+-------------------------------------------+----------------------------------+---------------------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+--------------------+-------+-------------------------------------------+----------------------------------+---------------------------+--------------+
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
+--------------------+-------+--------------------------------------- Page 1 of 1 --------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
// phpcs:enable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function explode;
|
||||
|
||||
class ListShortUrlsCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
@@ -209,6 +207,8 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
string $tagsMode,
|
||||
string|null $startDate = null,
|
||||
string|null $endDate = null,
|
||||
array $excludeTags = [],
|
||||
string $excludeTagsMode = TagsMode::ANY->value,
|
||||
): void {
|
||||
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
|
||||
'page' => $page,
|
||||
@@ -217,6 +217,8 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
'tagsMode' => $tagsMode,
|
||||
'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
|
||||
'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
|
||||
'excludeTags' => $excludeTags,
|
||||
'excludeTagsMode' => $excludeTagsMode,
|
||||
]))->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->setInputs(['n']);
|
||||
@@ -230,10 +232,10 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
yield [['--including-all-tags' => true], 1, null, [], TagsMode::ALL->value];
|
||||
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value];
|
||||
yield [
|
||||
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
|
||||
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tag' => $tags = ['foo', 'bar']],
|
||||
$page,
|
||||
$searchTerm,
|
||||
explode(',', $tags),
|
||||
$tags,
|
||||
TagsMode::ANY->value,
|
||||
];
|
||||
yield [
|
||||
@@ -262,6 +264,17 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
$startDate,
|
||||
$endDate,
|
||||
];
|
||||
yield [
|
||||
['--exclude-tag' => ['foo', 'bar'], '--exclude-tags-all' => true],
|
||||
1,
|
||||
null,
|
||||
[],
|
||||
TagsMode::ANY->value,
|
||||
null,
|
||||
null,
|
||||
['foo', 'bar'],
|
||||
TagsMode::ALL->value,
|
||||
];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideOrderBy')]
|
||||
|
||||
@@ -14,7 +14,7 @@ use function Shlinkio\Shlink\Core\normalizeOptionalDate;
|
||||
|
||||
final class ShortUrlsParams
|
||||
{
|
||||
public const DEFAULT_ITEMS_PER_PAGE = 10;
|
||||
public const int DEFAULT_ITEMS_PER_PAGE = 10;
|
||||
|
||||
private function __construct(
|
||||
public readonly int $page,
|
||||
@@ -27,6 +27,8 @@ final class ShortUrlsParams
|
||||
public readonly bool $excludePastValidUntil,
|
||||
public readonly TagsMode $tagsMode = TagsMode::ANY,
|
||||
public readonly string|null $domain = null,
|
||||
public readonly array $excludeTags = [],
|
||||
public readonly TagsMode $excludeTagsMode = TagsMode::ANY,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -61,6 +63,10 @@ final class ShortUrlsParams
|
||||
excludePastValidUntil: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL),
|
||||
tagsMode: self::resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)),
|
||||
domain: $inputFilter->getValue(ShortUrlsParamsInputFilter::DOMAIN),
|
||||
excludeTags: (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_TAGS),
|
||||
excludeTagsMode: self::resolveTagsMode(
|
||||
$inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
|
||||
|
||||
use Laminas\InputFilter\Input;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Laminas\Validator\InArray;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
@@ -19,10 +20,12 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
public const string PAGE = 'page';
|
||||
public const string SEARCH_TERM = 'searchTerm';
|
||||
public const string TAGS = 'tags';
|
||||
public const string TAGS_MODE = 'tagsMode';
|
||||
public const string EXCLUDE_TAGS = 'excludeTags';
|
||||
public const string EXCLUDE_TAGS_MODE = 'excludeTagsMode';
|
||||
public const string START_DATE = 'startDate';
|
||||
public const string END_DATE = 'endDate';
|
||||
public const string ITEMS_PER_PAGE = 'itemsPerPage';
|
||||
public const string TAGS_MODE = 'tagsMode';
|
||||
public const string ORDER_BY = 'orderBy';
|
||||
public const string EXCLUDE_MAX_VISITS_REACHED = 'excludeMaxVisitsReached';
|
||||
public const string EXCLUDE_PAST_VALID_UNTIL = 'excludePastValidUntil';
|
||||
@@ -45,13 +48,10 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
$this->add(InputFactory::numeric(self::ITEMS_PER_PAGE, Paginator::ALL_ITEMS));
|
||||
|
||||
$this->add(InputFactory::tags(self::TAGS));
|
||||
$this->add($this->createTagsModeInput(self::TAGS_MODE));
|
||||
|
||||
$tagsMode = InputFactory::basic(self::TAGS_MODE);
|
||||
$tagsMode->getValidatorChain()->attach(new InArray([
|
||||
'haystack' => enumValues(TagsMode::class),
|
||||
'strict' => InArray::COMPARE_STRICT,
|
||||
]));
|
||||
$this->add($tagsMode);
|
||||
$this->add(InputFactory::tags(self::EXCLUDE_TAGS));
|
||||
$this->add($this->createTagsModeInput(self::EXCLUDE_TAGS_MODE));
|
||||
|
||||
$this->add(InputFactory::orderBy(self::ORDER_BY, enumValues(OrderableField::class)));
|
||||
|
||||
@@ -60,4 +60,15 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
|
||||
$this->add(InputFactory::basic(self::DOMAIN));
|
||||
}
|
||||
|
||||
private function createTagsModeInput(string $name): Input
|
||||
{
|
||||
$tagsMode = InputFactory::basic($name);
|
||||
$tagsMode->getValidatorChain()->attach(new InArray([
|
||||
'haystack' => enumValues(TagsMode::class),
|
||||
'strict' => InArray::COMPARE_STRICT,
|
||||
]));
|
||||
|
||||
return $tagsMode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,21 @@ class ShortUrlsCountFiltering
|
||||
{
|
||||
public readonly bool $searchIncludesDefaultDomain;
|
||||
|
||||
/**
|
||||
* @param $defaultDomain - Used only to determine if search term includes default domain
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string|null $searchTerm = null,
|
||||
public readonly array $tags = [],
|
||||
public readonly TagsMode|null $tagsMode = null,
|
||||
public readonly TagsMode $tagsMode = TagsMode::ANY,
|
||||
public readonly DateRange|null $dateRange = null,
|
||||
public readonly bool $excludeMaxVisitsReached = false,
|
||||
public readonly bool $excludePastValidUntil = false,
|
||||
public readonly ApiKey|null $apiKey = null,
|
||||
string|null $defaultDomain = null,
|
||||
public readonly string|null $domain = null,
|
||||
public readonly array $excludeTags = [],
|
||||
public readonly TagsMode $excludeTagsMode = TagsMode::ANY,
|
||||
) {
|
||||
$this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains(
|
||||
strtolower($defaultDomain),
|
||||
@@ -45,6 +50,8 @@ class ShortUrlsCountFiltering
|
||||
$apiKey,
|
||||
$defaultDomain,
|
||||
$params->domain,
|
||||
$params->excludeTags,
|
||||
$params->excludeTagsMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,24 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class ShortUrlsListFiltering extends ShortUrlsCountFiltering
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int|null $limit = null,
|
||||
public readonly int|null $offset = null,
|
||||
public readonly Ordering $orderBy = new Ordering(),
|
||||
string|null $searchTerm = null,
|
||||
array $tags = [],
|
||||
TagsMode|null $tagsMode = null,
|
||||
TagsMode $tagsMode = TagsMode::ANY,
|
||||
DateRange|null $dateRange = null,
|
||||
bool $excludeMaxVisitsReached = false,
|
||||
bool $excludePastValidUntil = false,
|
||||
ApiKey|null $apiKey = null,
|
||||
// Used only to determine if search term includes default domain
|
||||
string|null $defaultDomain = null,
|
||||
string|null $domain = null,
|
||||
array $excludeTags = [],
|
||||
TagsMode $excludeTagsMode = TagsMode::ANY,
|
||||
) {
|
||||
parent::__construct(
|
||||
$searchTerm,
|
||||
@@ -37,6 +41,8 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering
|
||||
$apiKey,
|
||||
$defaultDomain,
|
||||
$domain,
|
||||
$excludeTags,
|
||||
$excludeTagsMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +66,8 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering
|
||||
$apiKey,
|
||||
$defaultDomain,
|
||||
$params->domain,
|
||||
$params->excludeTags,
|
||||
$params->excludeTagsMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
|
||||
|
||||
$searchTerm = $filtering->searchTerm;
|
||||
$tags = $filtering->tags;
|
||||
$tagsMode = $filtering->tagsMode;
|
||||
$excludeTags = $filtering->excludeTags;
|
||||
$excludeTagsMode = $filtering->excludeTagsMode;
|
||||
|
||||
if (! empty($searchTerm)) {
|
||||
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
|
||||
if (empty($tags)) {
|
||||
@@ -125,7 +129,6 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
|
||||
}
|
||||
|
||||
// Apply tag conditions, only when not filtering by all provided tags
|
||||
$tagsMode = $filtering->tagsMode ?? TagsMode::ANY;
|
||||
if (empty($tags) || $tagsMode === TagsMode::ANY) {
|
||||
$conditions[] = $qb->expr()->like('t.name', ':searchPattern');
|
||||
}
|
||||
@@ -136,10 +139,26 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
|
||||
|
||||
// Filter by tags if provided
|
||||
if (! empty($tags)) {
|
||||
$tagsMode = $filtering->tagsMode ?? TagsMode::ANY;
|
||||
$tagsMode === TagsMode::ANY
|
||||
? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags))
|
||||
: $this->joinAllTags($qb, $tags);
|
||||
if ($tagsMode === TagsMode::ANY) {
|
||||
$qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags));
|
||||
} else {
|
||||
$this->joinAllTags($qb, $tags);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by excludeTags if provided
|
||||
if (! empty($excludeTags)) {
|
||||
$subQb = $this->getEntityManager()->createQueryBuilder();
|
||||
$subQb->select('s2.id')
|
||||
->from(ShortUrl::class, 's2');
|
||||
|
||||
if ($excludeTagsMode === TagsMode::ANY) {
|
||||
$subQb->join('s2.tags', 't2')->andWhere($qb->expr()->in('t2.name', $excludeTags));
|
||||
} else {
|
||||
$this->joinAllTags($subQb, $excludeTags, shortUrlsAlias: 's2', boundParamsQb: $qb);
|
||||
}
|
||||
|
||||
$qb->andWhere($qb->expr()->notIn('s.id', $subQb->getDQL()));
|
||||
}
|
||||
|
||||
if ($filtering->domain !== null) {
|
||||
@@ -178,12 +197,27 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
|
||||
return $qb;
|
||||
}
|
||||
|
||||
private function joinAllTags(QueryBuilder $qb, array $tags): void
|
||||
{
|
||||
/**
|
||||
* @param $boundParamsQb - The query builder in which params should be bound, in case the main provided QB is going
|
||||
* to be used as a sub query, since params need to be bound in the parent query.
|
||||
* Defaults to the main $qb
|
||||
*/
|
||||
private function joinAllTags(
|
||||
QueryBuilder $qb,
|
||||
array $tags,
|
||||
string $shortUrlsAlias = 's',
|
||||
QueryBuilder|null $boundParamsQb = null,
|
||||
): void {
|
||||
$boundParamsQb ??= $qb;
|
||||
foreach ($tags as $index => $tag) {
|
||||
$alias = 't_' . $index;
|
||||
$qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index)
|
||||
->setParameter('tag' . $index, $tag);
|
||||
$alias = 't_' . $index . $shortUrlsAlias;
|
||||
$qb->join(
|
||||
$shortUrlsAlias . '.tags',
|
||||
$alias,
|
||||
Join::WITH,
|
||||
$alias . '.name = :tag' . $index . $shortUrlsAlias,
|
||||
);
|
||||
$boundParamsQb->setParameter('tag' . $index . $shortUrlsAlias, $tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +239,24 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
|
||||
self::assertEquals(0, $this->repo->countList(
|
||||
new ShortUrlsCountFiltering(tags: ['foo', 'bar', 'baz'], tagsMode: TagsMode::ALL),
|
||||
));
|
||||
|
||||
self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering(excludeTags: ['foo'])));
|
||||
self::assertEquals(0, $this->repo->countList(new ShortUrlsCountFiltering(excludeTags: ['foo', 'bar'])));
|
||||
self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(
|
||||
excludeTags: ['foo', 'bar'],
|
||||
excludeTagsMode: TagsMode::ALL,
|
||||
)));
|
||||
|
||||
self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering(tags: ['foo'], excludeTags: ['bar'])));
|
||||
self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(
|
||||
tags: ['foo'],
|
||||
excludeTags: ['bar', 'baz'],
|
||||
)));
|
||||
self::assertEquals(3, $this->repo->countList(new ShortUrlsCountFiltering(
|
||||
tags: ['foo'],
|
||||
excludeTags: ['bar', 'baz'],
|
||||
excludeTagsMode: TagsMode::ALL,
|
||||
)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -153,8 +153,11 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
];
|
||||
|
||||
#[Test, DataProvider('provideFilteredLists')]
|
||||
public function shortUrlsAreProperlyListed(array $query, array $expectedShortUrls, string $apiKey): void
|
||||
{
|
||||
public function shortUrlsAreProperlyListed(
|
||||
array $query,
|
||||
array $expectedShortUrls,
|
||||
string $apiKey = 'valid_api_key',
|
||||
): void {
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query], $apiKey);
|
||||
$respPayload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
@@ -176,21 +179,21 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_DOCS,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['excludePastValidUntil' => 'true'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['excludeMaxVisitsReached' => 'true'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_DOCS,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['orderBy' => 'shortCode'], [
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
@@ -198,7 +201,7 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_DOCS,
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['orderBy' => 'shortCode-DESC'], [
|
||||
self::SHORT_URL_DOCS,
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
@@ -206,7 +209,7 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['orderBy' => 'title-DESC'], [
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_META,
|
||||
@@ -214,66 +217,87 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
self::SHORT_URL_DOCS,
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_DOCS,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo']], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['bar']], [
|
||||
self::SHORT_URL_META,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo', 'bar']], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'any'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'all'], [
|
||||
self::SHORT_URL_META,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo', 'bar', 'baz']], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
yield [['tags' => ['foo', 'bar', 'baz'], 'tagsMode' => 'all'], [], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo', 'bar', 'baz'], 'tagsMode' => 'all'], []];
|
||||
yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['searchTerm' => 'alejandro'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['searchTerm' => 'cool'], [
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['searchTerm' => 'example.com'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['domain' => 'example.com'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['domain' => Domain::DEFAULT_AUTHORITY], [
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_DOCS,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
|
||||
// Exclude tags
|
||||
yield [['excludeTags' => ['foo']], [
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_DOCS,
|
||||
]];
|
||||
yield [['excludeTags' => ['foo', 'bar']], [
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_DOCS,
|
||||
]];
|
||||
yield [['excludeTags' => ['bar', 'foo'], 'excludeTagsMode' => 'all'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_DOCS,
|
||||
]];
|
||||
|
||||
// Different API keys
|
||||
yield [[], [
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
|
||||
Reference in New Issue
Block a user