diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 83e7bc2e..0c0daf33 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -64,6 +64,12 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand InputOption::VALUE_REQUIRED, 'A comma-separated list of tags to filter results.', ) + ->addOption( + 'including-all-tags', + 'i', + InputOption::VALUE_NONE, + 'If tags is provided, returns only short URLs having ALL tags.', + ) ->addOption( 'order-by', 'o', @@ -115,6 +121,9 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $page = (int) $input->getOption('page'); $searchTerm = $input->getOption('search-term'); $tags = $input->getOption('tags'); + $tagsMode = $input->getOption('including-all-tags') === true + ? ShortUrlsParams::TAGS_MODE_ALL + : ShortUrlsParams::TAGS_MODE_ANY; $tags = ! empty($tags) ? explode(',', $tags) : []; $all = $input->getOption('all'); $startDate = $this->getStartDateOption($input, $output); @@ -125,6 +134,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $data = [ ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm, ShortUrlsParamsInputFilter::TAGS => $tags, + ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode, ShortUrlsOrdering::ORDER_BY => $orderBy, ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(), ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(), diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index e7dae690..97ced44c 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -184,6 +184,7 @@ class ListShortUrlsCommandTest extends TestCase ?int $page, ?string $searchTerm, array $tags, + string $tagsMode, ?string $startDate = null, ?string $endDate = null, ): void { @@ -191,6 +192,7 @@ class ListShortUrlsCommandTest extends TestCase 'page' => $page, 'searchTerm' => $searchTerm, 'tags' => $tags, + 'tagsMode' => $tagsMode, 'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null, 'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null, ]))->willReturn(new Paginator(new ArrayAdapter([]))); @@ -203,20 +205,23 @@ class ListShortUrlsCommandTest extends TestCase public function provideArgs(): iterable { - yield [[], 1, null, []]; - yield [['--page' => $page = 3], $page, null, []]; - yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, []]; + yield [[], 1, null, [], ShortUrlsParams::TAGS_MODE_ANY]; + yield [['--page' => $page = 3], $page, null, [], ShortUrlsParams::TAGS_MODE_ANY]; + yield [['--including-all-tags' => true], 1, null, [], ShortUrlsParams::TAGS_MODE_ALL]; + yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], ShortUrlsParams::TAGS_MODE_ANY]; yield [ ['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'], $page, $searchTerm, explode(',', $tags), + ShortUrlsParams::TAGS_MODE_ANY, ]; yield [ ['--start-date' => $startDate = '2019-01-01'], 1, null, [], + ShortUrlsParams::TAGS_MODE_ANY, $startDate, ]; yield [ @@ -224,6 +229,7 @@ class ListShortUrlsCommandTest extends TestCase 1, null, [], + ShortUrlsParams::TAGS_MODE_ANY, null, $endDate, ]; @@ -232,6 +238,7 @@ class ListShortUrlsCommandTest extends TestCase 1, null, [], + ShortUrlsParams::TAGS_MODE_ANY, $startDate, $endDate, ]; @@ -269,6 +276,7 @@ class ListShortUrlsCommandTest extends TestCase 'page' => 1, 'searchTerm' => null, 'tags' => [], + 'tagsMode' => ShortUrlsParams::TAGS_MODE_ANY, 'startDate' => null, 'endDate' => null, 'orderBy' => null, diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index aaa0bcca..5fe659f2 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; 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; use function array_column; @@ -130,8 +131,10 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU // Filter by tags if provided if (! empty($tags)) { - $qb->join('s.tags', 't') - ->andWhere($qb->expr()->in('t.name', $tags)); + $tagsMode = $tagsMode ?? ShortUrlsParams::TAGS_MODE_ANY; + $tagsMode === ShortUrlsParams::TAGS_MODE_ANY + ? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags)) + : $this->joinAllTags($qb, $tags); } $this->applySpecification($qb, $spec, 's'); @@ -261,11 +264,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb->getQuery()->getOneOrNullResult(); } - foreach ($tags as $index => $tag) { - $alias = 't_' . $index; - $qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index) - ->setParameter('tag' . $index, $tag); - } + $this->joinAllTags($qb, $tags); // If tags where provided, we need an extra join to see the amount of tags that every short URL has, so that we // can discard those that also have more tags, making sure only those fully matching are included. @@ -277,6 +276,15 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb->getQuery()->getOneOrNullResult(); } + private function joinAllTags(QueryBuilder $qb, array $tags): void + { + foreach ($tags as $index => $tag) { + $alias = 't_' . $index; + $qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index) + ->setParameter('tag' . $index, $tag); + } + } + public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl { $qb = $this->createQueryBuilder('s'); diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 69ef7143..8cb161d4 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\Entity\Visit; 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; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; @@ -174,6 +175,71 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertEquals('z', $result[3]->getLongUrl()); } + /** @test */ + public function findListReturnsOnlyThoseWithMatchingTags(): void + { + $shortUrl1 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'longUrl' => 'foo1', + 'tags' => ['foo', 'bar'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl1); + $shortUrl2 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'longUrl' => 'foo2', + 'tags' => ['foo', 'baz'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl2); + $shortUrl3 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'longUrl' => 'foo3', + 'tags' => ['foo'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl3); + $shortUrl4 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'longUrl' => 'foo4', + 'tags' => ['bar', 'baz'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl4); + $shortUrl5 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'longUrl' => 'foo5', + 'tags' => ['bar', 'baz'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl5); + + $this->getEntityManager()->flush(); + + self::assertCount(5, $this->repo->findList(null, null, null, ['foo', 'bar'])); + self::assertCount(5, $this->repo->findList(null, null, null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY)); + self::assertCount(1, $this->repo->findList(null, null, null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL)); + self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar'])); + self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY)); + self::assertEquals(1, $this->repo->countList(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL)); + + self::assertCount(4, $this->repo->findList(null, null, null, ['bar', 'baz'])); + self::assertCount(4, $this->repo->findList(null, null, null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY)); + self::assertCount(2, $this->repo->findList(null, null, null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL)); + self::assertEquals(4, $this->repo->countList(null, ['bar', 'baz'])); + self::assertEquals(4, $this->repo->countList(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY)); + self::assertEquals(2, $this->repo->countList(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL)); + + self::assertCount(5, $this->repo->findList(null, null, null, ['foo', 'bar', 'baz'])); + self::assertCount(5, $this->repo->findList( + null, + null, + null, + ['foo', 'bar', 'baz'], + ShortUrlsParams::TAGS_MODE_ANY, + )); + self::assertCount(0, $this->repo->findList( + null, + null, + null, + ['foo', 'bar', 'baz'], + ShortUrlsParams::TAGS_MODE_ALL, + )); + self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar', 'baz'])); + self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY)); + self::assertEquals(0, $this->repo->countList(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL)); + } + /** @test */ public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void {