Created methods to get orphan visits lists

This commit is contained in:
Alejandro Celaya 2021-02-09 21:22:36 +01:00
parent 1fbcb44136
commit 85dd023c0e
7 changed files with 173 additions and 30 deletions

View File

@ -47,7 +47,7 @@
"predis/predis": "^1.1", "predis/predis": "^1.1",
"pugx/shortid-php": "^0.7", "pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9", "ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "dev-main#b889f5d as 3.5", "shlinkio/shlink-common": "dev-main#62d4b84 as 3.5",
"shlinkio/shlink-config": "^1.0", "shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^2.0", "shlinkio/shlink-event-dispatcher": "^2.0",
"shlinkio/shlink-importer": "^2.2", "shlinkio/shlink-importer": "^2.2",

View File

@ -28,15 +28,10 @@ class Visit extends AbstractEntity implements JsonSerializable
private ?ShortUrl $shortUrl; private ?ShortUrl $shortUrl;
private ?VisitLocation $visitLocation = null; private ?VisitLocation $visitLocation = null;
public function __construct( private function __construct(?ShortUrl $shortUrl, Visitor $visitor, string $type, bool $anonymize = true)
?ShortUrl $shortUrl, {
Visitor $visitor,
bool $anonymize = true,
?Chronos $date = null,
string $type = self::TYPE_VALID_SHORT_URL
) {
$this->shortUrl = $shortUrl; $this->shortUrl = $shortUrl;
$this->date = $date ?? Chronos::now(); $this->date = Chronos::now();
$this->userAgent = $visitor->getUserAgent(); $this->userAgent = $visitor->getUserAgent();
$this->referer = $visitor->getReferer(); $this->referer = $visitor->getReferer();
$this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress()); $this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress());
@ -60,22 +55,22 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self
{ {
return new self($shortUrl, $visitor, $anonymize); return new self($shortUrl, $visitor, self::TYPE_VALID_SHORT_URL, $anonymize);
} }
public static function forBasePath(Visitor $visitor, bool $anonymize = true): self public static function forBasePath(Visitor $visitor, bool $anonymize = true): self
{ {
return new self(null, $visitor, $anonymize, null, self::TYPE_BASE_URL); return new self(null, $visitor, self::TYPE_BASE_URL, $anonymize);
} }
public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self
{ {
return new self(null, $visitor, $anonymize, null, self::TYPE_INVALID_SHORT_URL); return new self(null, $visitor, self::TYPE_INVALID_SHORT_URL, $anonymize);
} }
public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self
{ {
return new self(null, $visitor, $anonymize, null, self::TYPE_REGULAR_404); return new self(null, $visitor, self::TYPE_REGULAR_404, $anonymize);
} }
public function getRemoteAddr(): ?string public function getRemoteAddr(): ?string

View File

@ -168,6 +168,29 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
return $qb; return $qb;
} }
public function findOrphanVisits(?DateRange $dateRange = null, ?int $limit = null, ?int $offset = null): array
{
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
// Since they are not strictly provided by the caller, it's reasonably safe
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v')
->where($qb->expr()->isNull('v.shortUrl'));
$this->applyDatesInline($qb, $dateRange);
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
}
public function countOrphanVisits(?DateRange $dateRange = null): int
{
return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($dateRange));
}
public function countVisits(?ApiKey $apiKey = null): int
{
return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($apiKey));
}
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
{ {
if ($dateRange !== null && $dateRange->getStartDate() !== null) { if ($dateRange !== null && $dateRange->getStartDate() !== null) {
@ -208,14 +231,4 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
return $query->getResult(); return $query->getResult();
} }
public function countVisits(?ApiKey $apiKey = null): int
{
return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($apiKey));
}
public function countOrphanVisits(): int
{
return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits());
}
} }

View File

@ -62,7 +62,12 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int; public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int;
public function countVisits(?ApiKey $apiKey = null): int; /**
* @return Visit[]
*/
public function findOrphanVisits(?DateRange $dateRange = null, ?int $limit = null, ?int $offset = null): array;
public function countOrphanVisits(): int; public function countOrphanVisits(?DateRange $dateRange = null): int;
public function countVisits(?ApiKey $apiKey = null): int;
} }

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Spec;
use Happyr\DoctrineSpecification\BaseSpecification;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Util\DateRange;
class InDateRange extends BaseSpecification
{
private ?DateRange $dateRange;
private string $field;
public function __construct(?DateRange $dateRange, string $field = 'date')
{
parent::__construct();
$this->dateRange = $dateRange;
$this->field = $field;
}
protected function getSpec(): Specification
{
$criteria = [];
if ($this->dateRange !== null && $this->dateRange->getStartDate() !== null) {
$criteria[] = Spec::gte($this->field, $this->dateRange->getStartDate()->toDateTimeString());
}
if ($this->dateRange !== null && $this->dateRange->getEndDate() !== null) {
$criteria[] = Spec::lte($this->field, $this->dateRange->getEndDate()->toDateTimeString());
}
return Spec::andX(...$criteria);
}
}

View File

@ -7,11 +7,24 @@ namespace Shlinkio\Shlink\Core\Visit\Spec;
use Happyr\DoctrineSpecification\BaseSpecification; use Happyr\DoctrineSpecification\BaseSpecification;
use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\Specification; use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Spec\InDateRange;
class CountOfOrphanVisits extends BaseSpecification class CountOfOrphanVisits extends BaseSpecification
{ {
private ?DateRange $dateRange;
public function __construct(?DateRange $dateRange)
{
parent::__construct();
$this->dateRange = $dateRange;
}
protected function getSpec(): Specification protected function getSpec(): Specification
{ {
return Spec::countOf(Spec::isNull('shortUrl')); return Spec::countOf(Spec::andX(
Spec::isNull('shortUrl'),
new InDateRange($this->dateRange),
));
} }
} }

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Repository; namespace ShlinkioTest\Shlink\Core\Repository;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use ReflectionObject;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
@ -214,6 +215,75 @@ class VisitRepositoryTest extends DatabaseTestCase
self::assertEquals(3, $this->repo->countOrphanVisits()); self::assertEquals(3, $this->repo->countOrphanVisits());
} }
/** @test */
public function findOrphanVisitsReturnsExpectedResult(): void
{
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => '']));
$this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl, 7);
for ($i = 0; $i < 6; $i++) {
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forBasePath(Visitor::emptyInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forInvalidShortUrl(Visitor::emptyInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forRegularNotFound(Visitor::emptyInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
}
$this->getEntityManager()->flush();
self::assertCount(18, $this->repo->findOrphanVisits());
self::assertCount(5, $this->repo->findOrphanVisits(null, 5));
self::assertCount(10, $this->repo->findOrphanVisits(null, 15, 8));
self::assertCount(9, $this->repo->findOrphanVisits(DateRange::withStartDate(Chronos::parse('2020-01-04')), 15));
self::assertCount(2, $this->repo->findOrphanVisits(
DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')),
6,
4,
));
self::assertCount(3, $this->repo->findOrphanVisits(DateRange::withEndDate(Chronos::parse('2020-01-01'))));
}
/** @test */
public function countOrphanVisitsReturnsExpectedResult(): void
{
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => '']));
$this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl, 7);
for ($i = 0; $i < 6; $i++) {
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forBasePath(Visitor::emptyInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forInvalidShortUrl(Visitor::emptyInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forRegularNotFound(Visitor::emptyInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
}
$this->getEntityManager()->flush();
self::assertEquals(18, $this->repo->countOrphanVisits());
self::assertEquals(18, $this->repo->countOrphanVisits(DateRange::emptyInstance()));
self::assertEquals(9, $this->repo->countOrphanVisits(DateRange::withStartDate(Chronos::parse('2020-01-04'))));
self::assertEquals(6, $this->repo->countOrphanVisits(
DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')),
));
self::assertEquals(3, $this->repo->countOrphanVisits(DateRange::withEndDate(Chronos::parse('2020-01-01'))));
}
private function createShortUrlsAndVisits(bool $withDomain = true, array $tags = []): array private function createShortUrlsAndVisits(bool $withDomain = true, array $tags = []): array
{ {
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
@ -243,13 +313,22 @@ class VisitRepositoryTest extends DatabaseTestCase
private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6): void private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6): void
{ {
for ($i = 0; $i < $amount; $i++) { for ($i = 0; $i < $amount; $i++) {
$visit = new Visit( $visit = $this->setDateOnVisit(
$shortUrl, Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()),
Visitor::emptyInstance(),
true,
Chronos::parse(sprintf('2016-01-0%s', $i + 1)), Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
); );
$this->getEntityManager()->persist($visit); $this->getEntityManager()->persist($visit);
} }
} }
private function setDateOnVisit(Visit $visit, Chronos $date): Visit
{
$ref = new ReflectionObject($visit);
$dateProp = $ref->getProperty('date');
$dateProp->setAccessible(true);
$dateProp->setValue($visit, $date);
return $visit;
}
} }