mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-17 15:59:56 +03:00
Add new table to track short URL visits counts
This commit is contained in:
parent
14702063f2
commit
17d37a062a
8 changed files with 216 additions and 16 deletions
|
@ -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')
|
||||
|
|
|
@ -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();
|
||||
};
|
63
module/Core/migrations/Version20240306132518.php
Normal file
63
module/Core/migrations/Version20240306132518.php
Normal 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);
|
||||
}
|
||||
}
|
59
module/Core/migrations/Version20240318084804.php
Normal file
59
module/Core/migrations/Version20240318084804.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
19
module/Core/src/Visit/Entity/ShortUrlVisitsCount.php
Normal file
19
module/Core/src/Visit/Entity/ShortUrlVisitsCount.php
Normal 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',
|
||||
) {
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue