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);