Skip to content

Commit

Permalink
Track orphan visits counts
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Apr 1, 2024
1 parent b50547d commit d090260
Show file tree
Hide file tree
Showing 13 changed files with 349 additions and 13 deletions.
5 changes: 3 additions & 2 deletions config/autoload/entity-manager.global.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Doctrine\ORM\Events;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Visit\Listener\OrphanVisitsCountTracker;
use Shlinkio\Shlink\Core\Visit\Listener\ShortUrlVisitsCountTracker;

use function Shlinkio\Shlink\Core\ArrayUtils\contains;
Expand Down Expand Up @@ -63,8 +64,8 @@
'load_mappings_using_functional_style' => true,
'default_repository_classname' => EntitySpecificationRepository::class,
'listeners' => [
Events::onFlush => [ShortUrlVisitsCountTracker::class],
Events::postFlush => [ShortUrlVisitsCountTracker::class],
Events::onFlush => [ShortUrlVisitsCountTracker::class, OrphanVisitsCountTracker::class],
Events::postFlush => [ShortUrlVisitsCountTracker::class, OrphanVisitsCountTracker::class],
],
],
'connection' => $resolveConnection(),
Expand Down
1 change: 1 addition & 0 deletions module/Core/config/dependencies.config.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
Visit\Entity\Visit::class,
],
Visit\Listener\ShortUrlVisitsCountTracker::class => InvokableFactory::class,
Visit\Listener\OrphanVisitsCountTracker::class => InvokableFactory::class,

Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Core;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;

return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);

$builder->setTable(determineTableName('orphan_visits_counts', $emConfig))
->setCustomRepositoryClass(Visit\Repository\OrphanVisitsCountRepository::class);

$builder->createField('id', Types::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();

$builder->createField('potentialBot', Types::BOOLEAN)
->columnName('potential_bot')
->option('default', false)
->build();

$builder->createField('count', Types::BIGINT)
->columnName('count')
->option('unsigned', true)
->option('default', 1)
->build();

$builder->createField('slotId', Types::INTEGER)
->columnName('slot_id')
->option('unsigned', true)
->build();

$builder->addUniqueConstraint(['potential_bot', 'slot_id'], 'UQ_slot');
};
17 changes: 17 additions & 0 deletions module/Core/src/Visit/Entity/OrphanVisitsCount.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Core\Visit\Entity;

use Shlinkio\Shlink\Common\Entity\AbstractEntity;

class OrphanVisitsCount extends AbstractEntity
{
public function __construct(

Check warning on line 11 in module/Core/src/Visit/Entity/OrphanVisitsCount.php

View check run for this annotation

Codecov / codecov/patch

module/Core/src/Visit/Entity/OrphanVisitsCount.php#L11

Added line #L11 was not covered by tests
public readonly bool $potentialBot = false,
public readonly int $slotId = 1,
public readonly string $count = '1',
) {
}
}
145 changes: 145 additions & 0 deletions module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Core\Visit\Listener;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;

use function rand;

final class OrphanVisitsCountTracker
{
/** @var object[] */
private array $entitiesToBeCreated = [];

public function onFlush(OnFlushEventArgs $args): void
{
// Track entities that are going to be created during this flush operation
$this->entitiesToBeCreated = $args->getObjectManager()->getUnitOfWork()->getScheduledEntityInsertions();
}

/**
* @throws Exception
*/
public function postFlush(PostFlushEventArgs $args): void
{
$em = $args->getObjectManager();
$entitiesToBeCreated = $this->entitiesToBeCreated;

// Reset tracked entities until next flush operation
$this->entitiesToBeCreated = [];

foreach ($entitiesToBeCreated as $entity) {
$this->trackVisitCount($em, $entity);
}
}

/**
* @throws Exception
*/
private function trackVisitCount(EntityManagerInterface $em, object $entity): void
{
// This is not an orphan visit
if (! $entity instanceof Visit || ! $entity->isOrphan()) {
return;
}
$visit = $entity;

$isBot = $visit->potentialBot;
$conn = $em->getConnection();
$platformClass = $conn->getDatabasePlatform();

match ($platformClass::class) {
PostgreSQLPlatform::class => $this->incrementForPostgres($conn, $isBot),
SQLitePlatform::class, SQLServerPlatform::class => $this->incrementForOthers($conn, $isBot),
default => $this->incrementForMySQL($conn, $isBot),
};
}

/**
* @throws Exception
*/
private function incrementForMySQL(Connection $conn, bool $potentialBot): void

Check warning on line 71 in module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php

View check run for this annotation

Codecov / codecov/patch

module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php#L71

Added line #L71 was not covered by tests
{
$this->incrementWithPreparedStatement($conn, $potentialBot, <<<QUERY

Check warning on line 73 in module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php

View check run for this annotation

Codecov / codecov/patch

module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php#L73

Added line #L73 was not covered by tests
INSERT INTO orphan_visits_counts (potential_bot, slot_id, count)
VALUES (:potential_bot, RAND() * 100, 1)
ON DUPLICATE KEY UPDATE count = count + 1;
QUERY);

Check warning on line 77 in module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php

View check run for this annotation

Codecov / codecov/patch

module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php#L77

Added line #L77 was not covered by tests
}

/**
* @throws Exception
*/
private function incrementForPostgres(Connection $conn, bool $potentialBot): void
{
$this->incrementWithPreparedStatement($conn, $potentialBot, <<<QUERY
INSERT INTO orphan_visits_counts (potential_bot, slot_id, count)
VALUES (:potential_bot, random() * 100, 1)
ON CONFLICT (potential_bot, slot_id) DO UPDATE
SET count = orphan_visits_counts.count + 1;
QUERY);
}

/**
* @throws Exception
*/
private function incrementWithPreparedStatement(Connection $conn, bool $potentialBot, string $query): void
{
$statement = $conn->prepare($query);
$statement->bindValue('potential_bot', $potentialBot ? 1 : 0);
$statement->executeStatement();
}

/**
* @throws Exception
*/
private function incrementForOthers(Connection $conn, bool $potentialBot): void

Check warning on line 106 in module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php

View check run for this annotation

Codecov / codecov/patch

module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php#L106

Added line #L106 was not covered by tests
{
$slotId = rand(1, 100);

Check warning on line 108 in module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php

View check run for this annotation

Codecov / codecov/patch

module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php#L108

Added line #L108 was not covered by tests

// For engines without a specific UPSERT syntax, do a regular locked select followed by an insert or update
$qb = $conn->createQueryBuilder();
$qb->select('id')
->from('orphan_visits_counts')
->where($qb->expr()->and(
$qb->expr()->eq('potential_bot', ':potential_bot'),
$qb->expr()->eq('slot_id', ':slot_id'),
))
->setParameter('potential_bot', $potentialBot ? '1' : '0')
->setParameter('slot_id', $slotId)
->setMaxResults(1);

Check warning on line 120 in module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php

View check run for this annotation

Codecov / codecov/patch

module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php#L111-L120

Added lines #L111 - L120 were not covered by tests

if ($conn->getDatabasePlatform()::class === SQLServerPlatform::class) {
$qb->forUpdate();

Check warning on line 123 in module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php

View check run for this annotation

Codecov / codecov/patch

module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php#L122-L123

Added lines #L122 - L123 were not covered by tests
}

$visitsCountId = $qb->executeQuery()->fetchOne();

Check warning on line 126 in module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php

View check run for this annotation

Codecov / codecov/patch

module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php#L126

Added line #L126 was not covered by tests

$writeQb = ! $visitsCountId
? $conn->createQueryBuilder()
->insert('orphan_visits_counts')
->values([
'potential_bot' => ':potential_bot',
'slot_id' => ':slot_id',
])
->setParameter('potential_bot', $potentialBot ? '1' : '0')
->setParameter('slot_id', $slotId)
: $conn->createQueryBuilder()
->update('orphan_visits_counts')
->set('count', 'count + 1')
->where($qb->expr()->eq('id', ':visits_count_id'))
->setParameter('visits_count_id', $visitsCountId);

Check warning on line 141 in module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php

View check run for this annotation

Codecov / codecov/patch

module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php#L128-L141

Added lines #L128 - L141 were not covered by tests

$writeQb->executeStatement();

Check warning on line 143 in module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php

View check run for this annotation

Codecov / codecov/patch

module/Core/src/Visit/Listener/OrphanVisitsCountTracker.php#L143

Added line #L143 was not covered by tests
}
}
31 changes: 31 additions & 0 deletions module/Core/src/Visit/Repository/OrphanVisitsCountRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Core\Visit\Repository;

use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Rest\ApiKey\Role;

class OrphanVisitsCountRepository extends EntitySpecificationRepository implements OrphanVisitsCountRepositoryInterface
{
public function countOrphanVisits(VisitsCountFiltering $filtering): int
{
if ($filtering->apiKey?->hasRole(Role::NO_ORPHAN_VISITS)) {
return 0;
}

$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('COALESCE(SUM(vc.count), 0)')
->from(OrphanVisitsCount::class, 'vc');

if ($filtering->excludeBots) {
$qb->andWhere($qb->expr()->eq('vc.potentialBot', ':potentialBot'))
->setParameter('potentialBot', false);
}

return (int) $qb->getQuery()->getSingleScalarResult();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Shlinkio\Shlink\Core\Visit\Repository;

use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;

interface OrphanVisitsCountRepositoryInterface
{
public function countOrphanVisits(VisitsCountFiltering $filtering): int;
}
13 changes: 8 additions & 5 deletions module/Core/src/Visit/VisitsStatsHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Repository\TagRepository;
use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount;
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
Expand All @@ -29,8 +30,8 @@
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\TagVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository;
use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;

Expand All @@ -42,18 +43,20 @@ public function __construct(private EntityManagerInterface $em)

public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats
{
/** @var VisitRepository $visitsRepo */
$visitsRepo = $this->em->getRepository(Visit::class);
/** @var OrphanVisitsCountRepository $orphanVisitsCountRepo */
$orphanVisitsCountRepo = $this->em->getRepository(OrphanVisitsCount::class);
/** @var ShortUrlVisitsCountRepository $visitsCountRepo */
$visitsCountRepo = $this->em->getRepository(ShortUrlVisitsCount::class);

return new VisitsStats(
nonOrphanVisitsTotal: $visitsCountRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey)),
orphanVisitsTotal: $visitsRepo->countOrphanVisits(new OrphanVisitsCountFiltering(apiKey: $apiKey)),
orphanVisitsTotal: $orphanVisitsCountRepo->countOrphanVisits(
new OrphanVisitsCountFiltering(apiKey: $apiKey),
),
nonOrphanVisitsNonBots: $visitsCountRepo->countNonOrphanVisits(
new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey),
),
orphanVisitsNonBots: $visitsRepo->countOrphanVisits(
orphanVisitsNonBots: $orphanVisitsCountRepo->countOrphanVisits(
new OrphanVisitsCountFiltering(excludeBots: true, apiKey: $apiKey),
),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace ShlinkioDbTest\Shlink\Core\Visit\Listener;

use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;

use function array_filter;
use function array_values;

class OrphanVisitsCountTrackerTest extends DatabaseTestCase
{
private EntityRepository $repo;

protected function setUp(): void
{
$this->repo = $this->getEntityManager()->getRepository(OrphanVisitsCount::class);
}

#[Test]
public function createsNewEntriesWhenNoneExist(): void
{
$visit = Visit::forBasePath(Visitor::emptyInstance());
$this->getEntityManager()->persist($visit);
$this->getEntityManager()->flush();

/** @var OrphanVisitsCount[] $result */
$result = $this->repo->findAll();

self::assertCount(1, $result);
self::assertEquals('1', $result[0]->count);
self::assertGreaterThanOrEqual(0, $result[0]->slotId);
self::assertLessThanOrEqual(100, $result[0]->slotId);
}

#[Test]
public function editsExistingEntriesWhenAlreadyExist(): void
{
for ($i = 0; $i <= 100; $i++) {
$this->getEntityManager()->persist(new OrphanVisitsCount(slotId: $i));
}
$this->getEntityManager()->flush();

$visit = Visit::forRegularNotFound(Visitor::emptyInstance());
$this->getEntityManager()->persist($visit);
$this->getEntityManager()->flush();

// Clear entity manager to force it to get fresh data from the database
// This is needed because the tracker inserts natively, bypassing the entity manager
$this->getEntityManager()->clear();

/** @var OrphanVisitsCount[] $result */
$result = $this->repo->findAll();
$itemsWithCountBiggerThanOnce = array_values(array_filter(
$result,
static fn (OrphanVisitsCount $item) => ((int) $item->count) > 1,
));

self::assertCount(101, $result);
self::assertCount(1, $itemsWithCountBiggerThanOnce);
self::assertEquals('2', $itemsWithCountBiggerThanOnce[0]->count);
}
}
Loading

0 comments on commit d090260

Please sign in to comment.