Enabled search by default domain

This commit is contained in:
Alejandro Celaya 2022-12-08 20:21:14 +01:00
parent 54bc169525
commit dfcac525bc
9 changed files with 108 additions and 151 deletions

View File

@ -108,6 +108,7 @@ return [
ShortUrl\ShortUrlResolver::class,
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
Options\UrlShortenerOptions::class,
],
Visit\Geolocation\VisitLocator::class => ['em'],
Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class],

View File

@ -17,16 +17,15 @@ final class ShortUrlsParams
public const ORDERABLE_FIELDS = ['longUrl', 'shortCode', 'dateCreated', 'title', 'visits'];
public const DEFAULT_ITEMS_PER_PAGE = 10;
private int $page;
private int $itemsPerPage;
private ?string $searchTerm;
private array $tags;
private TagsMode $tagsMode = TagsMode::ANY;
private Ordering $orderBy;
private ?DateRange $dateRange;
private function __construct()
{
private function __construct(
public readonly int $page,
public readonly int $itemsPerPage,
public readonly ?string $searchTerm,
public readonly array $tags,
public readonly Ordering $orderBy,
public readonly ?DateRange $dateRange,
public readonly TagsMode $tagsMode = TagsMode::ANY,
) {
}
public static function emptyInstance(): self
@ -38,38 +37,29 @@ final class ShortUrlsParams
* @throws ValidationException
*/
public static function fromRawData(array $query): self
{
$instance = new self();
$instance->validateAndInit($query);
return $instance;
}
/**
* @throws ValidationException
*/
private function validateAndInit(array $query): void
{
$inputFilter = new ShortUrlsParamsInputFilter($query);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}
$this->page = (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1);
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
$this->dateRange = buildDateRange(
normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
return new self(
page: (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1),
itemsPerPage: (int) (
$inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE
),
searchTerm: $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM),
tags: (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS),
orderBy: Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY)),
dateRange: buildDateRange(
normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
),
tagsMode: self::resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)),
);
$this->orderBy = Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY));
$this->itemsPerPage = (int) (
$inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE
);
$this->tagsMode = $this->resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE));
}
private function resolveTagsMode(?string $rawTagsMode): TagsMode
private static function resolveTagsMode(?string $rawTagsMode): TagsMode
{
if ($rawTagsMode === null) {
return TagsMode::ANY;
@ -77,39 +67,4 @@ final class ShortUrlsParams
return TagsMode::tryFrom($rawTagsMode) ?? TagsMode::ANY;
}
public function page(): int
{
return $this->page;
}
public function itemsPerPage(): int
{
return $this->itemsPerPage;
}
public function searchTerm(): ?string
{
return $this->searchTerm;
}
public function tags(): array
{
return $this->tags;
}
public function orderBy(): Ordering
{
return $this->orderBy;
}
public function dateRange(): ?DateRange
{
return $this->dateRange;
}
public function tagsMode(): TagsMode
{
return $this->tagsMode;
}
}

View File

@ -14,21 +14,28 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapter implements AdapterInterface
{
public function __construct(
private ShortUrlRepositoryInterface $repository,
private ShortUrlsParams $params,
private ?ApiKey $apiKey,
private readonly ShortUrlRepositoryInterface $repository,
private readonly ShortUrlsParams $params,
private readonly ?ApiKey $apiKey,
private readonly string $defaultDomain,
) {
}
public function getSlice(int $offset, int $length): iterable
{
return $this->repository->findList(
ShortUrlsListFiltering::fromLimitsAndParams($length, $offset, $this->params, $this->apiKey),
);
return $this->repository->findList(ShortUrlsListFiltering::fromLimitsAndParams(
$length,
$offset,
$this->params,
$this->apiKey,
$this->defaultDomain,
));
}
public function getNbResults(): int
{
return $this->repository->countList(ShortUrlsCountFiltering::fromParams($this->params, $this->apiKey));
return $this->repository->countList(
ShortUrlsCountFiltering::fromParams($this->params, $this->apiKey, $this->defaultDomain),
);
}
}

View File

@ -9,44 +9,36 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function str_contains;
use function strtolower;
class ShortUrlsCountFiltering
{
public readonly bool $searchIncludesDefaultDomain;
public function __construct(
private ?string $searchTerm = null,
private array $tags = [],
private ?TagsMode $tagsMode = null,
private ?DateRange $dateRange = null,
private ?ApiKey $apiKey = null,
public readonly ?string $searchTerm = null,
public readonly array $tags = [],
public readonly ?TagsMode $tagsMode = null,
public readonly ?DateRange $dateRange = null,
public readonly ?ApiKey $apiKey = null,
?string $defaultDomain = null,
) {
$this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains(
strtolower($defaultDomain),
strtolower($searchTerm),
);
}
public static function fromParams(ShortUrlsParams $params, ?ApiKey $apiKey): self
public static function fromParams(ShortUrlsParams $params, ?ApiKey $apiKey, string $defaultDomain): self
{
return new self($params->searchTerm(), $params->tags(), $params->tagsMode(), $params->dateRange(), $apiKey);
}
public function searchTerm(): ?string
{
return $this->searchTerm;
}
public function tags(): array
{
return $this->tags;
}
public function tagsMode(): ?TagsMode
{
return $this->tagsMode;
}
public function dateRange(): ?DateRange
{
return $this->dateRange;
}
public function apiKey(): ?ApiKey
{
return $this->apiKey;
return new self(
$params->searchTerm,
$params->tags,
$params->tagsMode,
$params->dateRange,
$apiKey,
$defaultDomain,
);
}
}

View File

@ -13,44 +13,36 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlsListFiltering extends ShortUrlsCountFiltering
{
public function __construct(
private ?int $limit,
private ?int $offset,
private Ordering $orderBy,
public readonly ?int $limit,
public readonly ?int $offset,
public readonly Ordering $orderBy,
?string $searchTerm = null,
array $tags = [],
?TagsMode $tagsMode = null,
?DateRange $dateRange = null,
?ApiKey $apiKey = null,
?string $defaultDomain = null,
) {
parent::__construct($searchTerm, $tags, $tagsMode, $dateRange, $apiKey);
parent::__construct($searchTerm, $tags, $tagsMode, $dateRange, $apiKey, $defaultDomain);
}
public static function fromLimitsAndParams(int $limit, int $offset, ShortUrlsParams $params, ?ApiKey $apiKey): self
{
public static function fromLimitsAndParams(
int $limit,
int $offset,
ShortUrlsParams $params,
?ApiKey $apiKey,
string $defaultDomain,
): self {
return new self(
$limit,
$offset,
$params->orderBy(),
$params->searchTerm(),
$params->tags(),
$params->tagsMode(),
$params->dateRange(),
$params->orderBy,
$params->searchTerm,
$params->tags,
$params->tagsMode,
$params->dateRange,
$apiKey,
$defaultDomain,
);
}
public function offset(): ?int
{
return $this->offset;
}
public function limit(): ?int
{
return $this->limit;
}
public function orderBy(): Ordering
{
return $this->orderBy;
}
}

View File

@ -33,12 +33,12 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
{
$qb = $this->createListQueryBuilder($filtering);
$qb->select('DISTINCT s')
->setMaxResults($filtering->limit())
->setFirstResult($filtering->offset());
->setMaxResults($filtering->limit)
->setFirstResult($filtering->offset);
// In case the ordering has been specified, the query could be more complex. Process it
if ($filtering->orderBy()->hasOrderField()) {
return $this->processOrderByForList($qb, $filtering->orderBy());
if ($filtering->orderBy->hasOrderField()) {
return $this->processOrderByForList($qb, $filtering->orderBy);
}
// With no explicit order by, fallback to dateCreated-DESC
@ -83,7 +83,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$qb->from(ShortUrl::class, 's')
->where('1=1');
$dateRange = $filtering->dateRange();
$dateRange = $filtering->dateRange;
if ($dateRange?->startDate !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
$qb->setParameter('startDate', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME);
@ -93,8 +93,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$qb->setParameter('endDate', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME);
}
$searchTerm = $filtering->searchTerm();
$tags = $filtering->tags();
$searchTerm = $filtering->searchTerm;
$tags = $filtering->tags;
// Apply search term to every searchable field if not empty
if (! empty($searchTerm)) {
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
@ -110,8 +110,13 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$qb->expr()->like('d.authority', ':searchPattern'),
];
// Include default domain in search if provided
if ($filtering->searchIncludesDefaultDomain) {
$conditions[] = $qb->expr()->isNull('s.domain');
}
// Apply tag conditions, only when not filtering by all provided tags
$tagsMode = $filtering->tagsMode() ?? TagsMode::ANY;
$tagsMode = $filtering->tagsMode ?? TagsMode::ANY;
if (empty($tags) || $tagsMode === TagsMode::ANY) {
$conditions[] = $qb->expr()->like('t.name', ':searchPattern');
}
@ -123,13 +128,13 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
// Filter by tags if provided
if (! empty($tags)) {
$tagsMode = $filtering->tagsMode() ?? TagsMode::ANY;
$tagsMode = $filtering->tagsMode ?? TagsMode::ANY;
$tagsMode === TagsMode::ANY
? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags))
: $this->joinAllTags($qb, $tags);
}
$this->applySpecification($qb, $filtering->apiKey()?->spec(), 's');
$this->applySpecification($qb, $filtering->apiKey?->spec(), 's');
return $qb;
}

View File

@ -8,6 +8,7 @@ use Doctrine\ORM;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
@ -25,6 +26,7 @@ class ShortUrlService implements ShortUrlServiceInterface
private readonly ShortUrlResolverInterface $urlResolver,
private readonly ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
private readonly ShortUrlRelationResolverInterface $relationResolver,
private readonly UrlShortenerOptions $urlShortenerOptions,
) {
}
@ -35,9 +37,10 @@ class ShortUrlService implements ShortUrlServiceInterface
{
/** @var ShortUrlRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params, $apiKey));
$paginator->setMaxPerPage($params->itemsPerPage())
->setCurrentPage($params->page());
$defaultDomain = $this->urlShortenerOptions->domain['hostname'] ?? '';
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params, $apiKey, $defaultDomain));
$paginator->setMaxPerPage($params->itemsPerPage)
->setCurrentPage($params->page);
return $paginator;
}

View File

@ -42,9 +42,9 @@ class ShortUrlRepositoryAdapterTest extends TestCase
'endDate' => $endDate,
'orderBy' => $orderBy,
]);
$adapter = new ShortUrlRepositoryAdapter($this->repo, $params, null);
$orderBy = $params->orderBy();
$dateRange = $params->dateRange();
$adapter = new ShortUrlRepositoryAdapter($this->repo, $params, null, '');
$orderBy = $params->orderBy;
$dateRange = $params->dateRange;
$this->repo->expects($this->once())->method('findList')->with(
new ShortUrlsListFiltering(10, 5, $orderBy, $searchTerm, $tags, TagsMode::ANY, $dateRange),
@ -70,8 +70,8 @@ class ShortUrlRepositoryAdapterTest extends TestCase
'endDate' => $endDate,
]);
$apiKey = ApiKey::create();
$adapter = new ShortUrlRepositoryAdapter($this->repo, $params, $apiKey);
$dateRange = $params->dateRange();
$adapter = new ShortUrlRepositoryAdapter($this->repo, $params, $apiKey, '');
$dateRange = $params->dateRange;
$this->repo->expects($this->once())->method('countList')->with(
new ShortUrlsCountFiltering($searchTerm, $tags, TagsMode::ANY, $dateRange, $apiKey),

View File

@ -8,6 +8,7 @@ use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
@ -45,6 +46,7 @@ class ShortUrlServiceTest extends TestCase
$this->urlResolver,
$this->titleResolutionHelper,
new SimpleShortUrlRelationResolver(),
new UrlShortenerOptions(),
);
}