From ac40a7021bf6153c7415155bd39a991f204a4b08 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 10 Oct 2025 12:06:45 +0200 Subject: [PATCH 1/9] Document excludeTags and excludeTagsMode params for short URLs list --- docs/swagger/paths/v1_short-urls.json | 28 +++++++++++++++++-- .../src/ShortUrl/Model/ShortUrlsParams.php | 4 ++- .../Validation/ShortUrlsParamsInputFilter.php | 25 ++++++++++++----- .../Persistence/ShortUrlsCountFiltering.php | 9 +++++- .../Persistence/ShortUrlsListFiltering.php | 12 ++++++-- .../Repository/ShortUrlListRepository.php | 4 +-- 6 files changed, 66 insertions(+), 16 deletions(-) diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 6ca05c2e..a29c221e 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -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\".
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\".
It's ignored if `excludeTags` is not provided.", "required": false, "schema": { "type": "string", diff --git a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php index 7b68ed37..1b1aea85 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php @@ -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, ) { } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php index bc9de337..1f44db48 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php @@ -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(InputFactory::tags(self::EXCLUDE_TAGS)); - $tagsMode = InputFactory::basic(self::TAGS_MODE); - $tagsMode->getValidatorChain()->attach(new InArray([ - 'haystack' => enumValues(TagsMode::class), - 'strict' => InArray::COMPARE_STRICT, - ])); - $this->add($tagsMode); + $this->add($this->createTagsModeInput(self::TAGS_MODE)); + $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; + } } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php index a8e42236..d793c314 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php @@ -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, ); } } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php index d0fa6418..f62f59d5 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -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, ); } } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index c18b31ef..a05f7b02 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -125,7 +125,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh } // Apply tag conditions, only when not filtering by all provided tags - $tagsMode = $filtering->tagsMode ?? TagsMode::ANY; + $tagsMode = $filtering->tagsMode; if (empty($tags) || $tagsMode === TagsMode::ANY) { $conditions[] = $qb->expr()->like('t.name', ':searchPattern'); } @@ -136,7 +136,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh // Filter by tags if provided if (! empty($tags)) { - $tagsMode = $filtering->tagsMode ?? TagsMode::ANY; + $tagsMode = $filtering->tagsMode; $tagsMode === TagsMode::ANY ? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags)) : $this->joinAllTags($qb, $tags); From 464e3d7f8eb68b56b48fa696a818348a57a546d2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 15 Oct 2025 10:16:31 +0200 Subject: [PATCH 2/9] Support excludeTags and excludeTagsMode in list short URLs command --- .../Command/ShortUrl/ListShortUrlsCommand.php | 34 +++++++++++++++---- .../src/ShortUrl/Model/ShortUrlsParams.php | 4 +++ .../Validation/ShortUrlsParamsInputFilter.php | 4 +-- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 5bdc4c81..a6774018 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -73,14 +73,25 @@ class ListShortUrlsCommand extends Command ->addOption( 'tags', 't', - InputOption::VALUE_REQUIRED, - 'A comma-separated list of tags to filter results.', + InputOption::VALUE_REQUIRED, // TODO Should be InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY + 'A comma-separated 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( - 'including-all-tags', - 'i', - InputOption::VALUE_NONE, - 'If tags is provided, returns only short URLs having ALL tags.', + 'exclude-tags', + 'et', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'A comma-separated list of tags that short URLs should not have.', + ) + ->addOption( + 'exclude-tags-all', + mode: InputOption::VALUE_NONE, + description: 'If --exclude-tags is provided, returns only short URLs not including ANY of them', ) ->addOption( 'exclude-max-visits-reached', @@ -136,9 +147,16 @@ 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) : []; + $tagsMode = $input->getOption('tags-all') === true || $input->getOption('including-all-tags') === true + ? TagsMode::ALL->value + : TagsMode::ANY->value; + + $excludeTags = $input->getOption('exclude-tags'); + $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 +168,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(), diff --git a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php index 1b1aea85..5b84ee10 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php @@ -63,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), + ), ); } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php index 1f44db48..7d29607c 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php @@ -48,9 +48,9 @@ class ShortUrlsParamsInputFilter extends InputFilter $this->add(InputFactory::numeric(self::ITEMS_PER_PAGE, Paginator::ALL_ITEMS)); $this->add(InputFactory::tags(self::TAGS)); - $this->add(InputFactory::tags(self::EXCLUDE_TAGS)); - $this->add($this->createTagsModeInput(self::TAGS_MODE)); + + $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))); From fe10aaf2457fd7be7d03239e7a24217d780dbeb9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 15 Oct 2025 10:23:11 +0200 Subject: [PATCH 3/9] Make --tags option allow multiple values in list short URLs command --- .../CLI/src/Command/ShortUrl/ListShortUrlsCommand.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index a6774018..32791f93 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -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; @@ -73,8 +74,8 @@ class ListShortUrlsCommand extends Command ->addOption( 'tags', 't', - InputOption::VALUE_REQUIRED, // TODO Should be InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY - 'A comma-separated list of tags that short URLs need to include.', + 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( @@ -86,7 +87,7 @@ class ListShortUrlsCommand extends Command 'exclude-tags', 'et', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - 'A comma-separated list of tags that short URLs should not have.', + 'A list of tags that short URLs should not have.', ) ->addOption( 'exclude-tags-all', @@ -148,8 +149,9 @@ class ListShortUrlsCommand extends Command $searchTerm = $input->getOption('search-term'); $domain = $input->getOption('domain'); + // FIXME DEPRECATED Remove support for comma-separated tags in next major release $tags = $input->getOption('tags'); - $tags = ! empty($tags) ? explode(',', $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; From 13c1b12d841e9f75099b503da7734102903c688e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 16 Oct 2025 09:51:36 +0200 Subject: [PATCH 4/9] Update logic in ShortUrlListRepository to take excluded tags into consideration --- .../Repository/ShortUrlListRepository.php | 50 +++++++++++++++---- .../Repository/ShortUrlListRepositoryTest.php | 18 +++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index a05f7b02..55116a6d 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; +use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; use function Shlinkio\Shlink\Core\ArrayUtils\map; @@ -105,6 +106,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 +130,6 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh } // Apply tag conditions, only when not filtering by all provided tags - $tagsMode = $filtering->tagsMode; if (empty($tags) || $tagsMode === TagsMode::ANY) { $conditions[] = $qb->expr()->like('t.name', ':searchPattern'); } @@ -136,10 +140,26 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh // Filter by tags if provided if (! empty($tags)) { - $tagsMode = $filtering->tagsMode; - $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 +198,22 @@ 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); } } } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index 435c3e58..ff300ee3 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -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] From 41c03a66e419da24fa33cd17509d178f8b773a10 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 16 Oct 2025 10:02:20 +0200 Subject: [PATCH 5/9] Fix static analysis --- .../src/ShortUrl/Repository/ShortUrlListRepository.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index 55116a6d..fcf80c6a 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -16,7 +16,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; -use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; use function Shlinkio\Shlink\Core\ArrayUtils\map; @@ -207,12 +206,17 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh QueryBuilder $qb, array $tags, string $shortUrlsAlias = 's', - QueryBuilder|null $boundParamsQb = null + QueryBuilder|null $boundParamsQb = null, ): void { $boundParamsQb ??= $qb; foreach ($tags as $index => $tag) { $alias = 't_' . $index . $shortUrlsAlias; - $qb->join($shortUrlsAlias . '.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index . $shortUrlsAlias); + $qb->join( + $shortUrlsAlias . '.tags', + $alias, + Join::WITH, + $alias . '.name = :tag' . $index . $shortUrlsAlias, + ); $boundParamsQb->setParameter('tag' . $index . $shortUrlsAlias, $tag); } } From 25de0263c5fde0f136073bec2e8a4d1520fe146f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Oct 2025 08:30:02 +0200 Subject: [PATCH 6/9] Deprecate --tags and add --tag for short-url:list command --- .../Command/ShortUrl/ListShortUrlsCommand.php | 13 ++++++++---- .../ShortUrl/ListShortUrlsCommandTest.php | 21 +++++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 32791f93..b7bbaf3a 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -73,6 +73,11 @@ class ListShortUrlsCommand extends Command ) ->addOption( 'tags', + mode: InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + description: '[DEPRECATED] Use --tag instead', + ) + ->addOption( + 'tag', 't', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'A list of tags that short URLs need to include.', @@ -84,7 +89,7 @@ class ListShortUrlsCommand extends Command description: 'If --tags is provided, returns only short URLs including ALL of them', ) ->addOption( - 'exclude-tags', + 'exclude-tag', 'et', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'A list of tags that short URLs should not have.', @@ -92,7 +97,7 @@ class ListShortUrlsCommand extends Command ->addOption( 'exclude-tags-all', mode: InputOption::VALUE_NONE, - description: 'If --exclude-tags is provided, returns only short URLs not including ANY of them', + description: 'If --exclude-tag is provided, returns only short URLs not including ANY of them', ) ->addOption( 'exclude-max-visits-reached', @@ -150,13 +155,13 @@ class ListShortUrlsCommand extends Command $domain = $input->getOption('domain'); // FIXME DEPRECATED Remove support for comma-separated tags in next major release - $tags = $input->getOption('tags'); + $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-tags'); + $excludeTags = $input->getOption('exclude-tag'); $excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value; $all = $input->getOption('all'); diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 0a7f9aa0..755d179c 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -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')] From eb199a61da49ea4a22969c98a992ebfe4c2b4bb8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Oct 2025 08:52:25 +0200 Subject: [PATCH 7/9] Add exclude-tags API tests --- .../test-api/Action/ListShortUrlsTest.php | 70 +++++++++++++------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 60b493d6..34aaa802 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -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, From fb9e8cd79faef16b4449fe92360e1b5d16857be2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Oct 2025 08:56:26 +0200 Subject: [PATCH 8/9] Update changelog --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc198d6..151a3109 100644 --- a/CHANGELOG.md +++ b/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* From 1c38ab12172e2621e6a2a344512eae65fa21c93b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 17 Oct 2025 09:26:18 +0200 Subject: [PATCH 9/9] Add exclude-tags CLI tests --- module/CLI/test-cli/Command/ListShortUrlsTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/module/CLI/test-cli/Command/ListShortUrlsTest.php b/module/CLI/test-cli/Command/ListShortUrlsTest.php index d7c50912..c01a631f 100644 --- a/module/CLI/test-cli/Command/ListShortUrlsTest.php +++ b/module/CLI/test-cli/Command/ListShortUrlsTest.php @@ -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'], <<