Add new table to track short URL visits counts

This commit is contained in:
Alejandro Celaya 2024-03-20 08:33:52 +01:00
parent 14702063f2
commit 17d37a062a
8 changed files with 216 additions and 16 deletions

View file

@ -67,6 +67,11 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->fetchExtraLazy()
->build();
$builder->createOneToMany('visitsCounts', Visit\Entity\ShortUrlVisitsCount::class)
->mappedBy('shortUrl')
->fetchExtraLazy() // TODO Check if this makes sense
->build();
$builder->createManyToMany('tags', Tag\Entity\Tag::class)
->setJoinTable(determineTableName('short_urls_in_tags', $emConfig))
->addInverseJoinColumn('tag_id', 'id', onDelete: 'CASCADE')

View file

@ -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('short_url_visits_counts', $emConfig));
$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)
->build();
$builder->createField('slotId', Types::INTEGER)
->columnName('slot_id')
->option('unsigned', true)
->build();
$builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class)
->addJoinColumn('short_url_id', 'id', onDelete: 'CASCADE')
->build();
};

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
/**
* Create the new short_url_visits_counts table that will track visit counts per short URL
*/
final class Version20240306132518 extends AbstractMigration
{
public function up(Schema $schema): void
{
$this->skipIf($schema->hasTable('short_url_visits_counts'));
$table = $schema->createTable('short_url_visits_counts');
$table->addColumn('id', Types::BIGINT, [
'unsigned' => true,
'autoincrement' => true,
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addColumn('short_url_id', Types::BIGINT, [
'unsigned' => true,
'notnull' => true,
]);
$table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [
'onDelete' => 'CASCADE',
'onUpdate' => 'RESTRICT',
]);
$table->addColumn('potential_bot', Types::BOOLEAN, ['default' => false]);
$table->addColumn('slot_id', Types::INTEGER, [
'unsigned' => true,
'notnull' => true,
'default' => 1,
]);
$table->addColumn('count', Types::BIGINT, [
'unsigned' => true,
'notnull' => true,
'default' => 1,
]);
}
public function down(Schema $schema): void
{
$this->skipIf(! $schema->hasTable('short_url_visits_counts'));
$schema->dropTable('short_url_visits_counts');
}
public function isTransactional(): bool
{
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Create initial entries in the short_url_visits_counts table for existing visits
*/
final class Version20240318084804 extends AbstractMigration
{
public function up(Schema $schema): void
{
$qb = $this->connection->createQueryBuilder();
$result = $qb->select('id')
->from('short_urls')
->executeQuery();
while ($shortUrlId = $result->fetchOne()) {
$visitsQb = $this->connection->createQueryBuilder();
$visitsQb->select('COUNT(id)')
->from('visits')
->where($visitsQb->expr()->eq('short_url_id', ':short_url_id'))
->andWhere($visitsQb->expr()->eq('potential_bot', ':potential_bot'))
->setParameter('short_url_id', $shortUrlId);
$botsCount = $visitsQb->setParameter('potential_bot', '1')->executeQuery()->fetchOne();
$nonBotsCount = $visitsQb->setParameter('potential_bot', '0')->executeQuery()->fetchOne();
$this->connection->createQueryBuilder()
->insert('short_url_visits_counts')
->values([
'short_url_id' => ':short_url_id',
'count' => ':count',
'potential_bot' => '1',
])
->setParameters([
'short_url_id' => $shortUrlId,
'count' => $botsCount,
])
->executeStatement();
$this->connection->createQueryBuilder()
->insert('short_url_visits_counts')
->values([
'short_url_id' => ':short_url_id',
'count' => ':count',
'potential_bot' => '0',
])
->setParameters([
'short_url_id' => $shortUrlId,
'count' => $nonBotsCount,
])
->executeStatement();
}
}
}

View file

@ -19,6 +19,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
@ -37,6 +38,7 @@ class ShortUrl extends AbstractEntity
/**
* @param Collection<int, Tag> $tags
* @param Collection<int, Visit> & Selectable $visits
* @param Collection<int, ShortUrlVisitsCount> & Selectable $visitsCounts
*/
private function __construct(
private string $longUrl,
@ -44,6 +46,7 @@ class ShortUrl extends AbstractEntity
private Chronos $dateCreated = new Chronos(),
private Collection $tags = new ArrayCollection(),
private Collection & Selectable $visits = new ArrayCollection(),
private Collection & Selectable $visitsCounts = new ArrayCollection(),
private ?Chronos $validSince = null,
private ?Chronos $validUntil = null,
private ?int $maxVisits = null,
@ -179,16 +182,16 @@ class ShortUrl extends AbstractEntity
return $this->shortCode;
}
public function getDateCreated(): Chronos
{
return $this->dateCreated;
}
public function getDomain(): ?Domain
{
return $this->domain;
}
public function forwardQuery(): bool
{
return $this->forwardQuery;
}
public function reachedVisits(int $visitsAmount): bool
{
return count($this->visits) >= $visitsAmount;
@ -214,11 +217,6 @@ class ShortUrl extends AbstractEntity
return $this;
}
public function forwardQuery(): bool
{
return $this->forwardQuery;
}
/**
* @throws ShortCodeCannotBeRegeneratedException
*/

View file

@ -60,12 +60,17 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
$leftJoinConditions[] = $qb->expr()->eq('v.potentialBot', 'false');
}
$qb->addSelect('SUM(v.count)')
->leftJoin('s.visitsCounts', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions))
->groupBy('s')
->orderBy('SUM(v.count)', $order);
// FIXME This query is inefficient.
// Diagnostic: It might need to use a sub-query, as done with the tags list query.
$qb->addSelect('COUNT(DISTINCT v)')
->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions))
->groupBy('s')
->orderBy('COUNT(DISTINCT v)', $order);
// $qb->addSelect('COUNT(DISTINCT v)')
// ->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions))
// ->groupBy('s')
// ->orderBy('COUNT(DISTINCT v)', $order);
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Entity;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
class ShortUrlVisitsCount extends AbstractEntity
{
public function __construct(
private readonly ShortUrl $shortUrl,
private readonly bool $potentialBot = false,
private readonly int $slotId = 1,
private readonly string $count = '1',
) {
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
@ -22,14 +23,23 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType;
class PublishingUpdatesGeneratorTest extends TestCase
{
private PublishingUpdatesGenerator $generator;
private Chronos $now;
protected function setUp(): void
{
$this->now = Chronos::now();
Chronos::setTestNow($this->now);
$this->generator = new PublishingUpdatesGenerator(
new ShortUrlDataTransformer(new ShortUrlStringifier([])),
);
}
protected function tearDown(): void
{
Chronos::setTestNow();
}
#[Test, DataProvider('provideMethod')]
public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic, ?string $title): void
{
@ -49,7 +59,7 @@ class PublishingUpdatesGeneratorTest extends TestCase
'shortCode' => $shortUrl->getShortCode(),
'shortUrl' => 'http:/' . $shortUrl->getShortCode(),
'longUrl' => 'https://longUrl',
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
'dateCreated' => $this->now->toAtomString(),
'tags' => [],
'meta' => [
'validSince' => null,
@ -123,7 +133,7 @@ class PublishingUpdatesGeneratorTest extends TestCase
'shortCode' => $shortUrl->getShortCode(),
'shortUrl' => 'http:/' . $shortUrl->getShortCode(),
'longUrl' => 'https://longUrl',
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
'dateCreated' => $this->now->toAtomString(),
'tags' => [],
'meta' => [
'validSince' => null,