From 17d37a062a010124ff39a84388a475cee4b64343 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Mar 2024 08:33:52 +0100 Subject: [PATCH] Add new table to track short URL visits counts --- ...o.Shlink.Core.ShortUrl.Entity.ShortUrl.php | 5 ++ ....Core.Visit.Entity.ShortUrlVisitsCount.php | 41 ++++++++++++ .../Core/migrations/Version20240306132518.php | 63 +++++++++++++++++++ .../Core/migrations/Version20240318084804.php | 59 +++++++++++++++++ module/Core/src/ShortUrl/Entity/ShortUrl.php | 18 +++--- .../Repository/ShortUrlListRepository.php | 13 ++-- .../src/Visit/Entity/ShortUrlVisitsCount.php | 19 ++++++ .../PublishingUpdatesGeneratorTest.php | 14 ++++- 8 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php create mode 100644 module/Core/migrations/Version20240306132518.php create mode 100644 module/Core/migrations/Version20240318084804.php create mode 100644 module/Core/src/Visit/Entity/ShortUrlVisitsCount.php diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index 358ee6bd..b159da13 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -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') diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php new file mode 100644 index 00000000..f65be80a --- /dev/null +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php @@ -0,0 +1,41 @@ +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(); +}; diff --git a/module/Core/migrations/Version20240306132518.php b/module/Core/migrations/Version20240306132518.php new file mode 100644 index 00000000..21847b81 --- /dev/null +++ b/module/Core/migrations/Version20240306132518.php @@ -0,0 +1,63 @@ +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); + } +} diff --git a/module/Core/migrations/Version20240318084804.php b/module/Core/migrations/Version20240318084804.php new file mode 100644 index 00000000..6b906107 --- /dev/null +++ b/module/Core/migrations/Version20240318084804.php @@ -0,0 +1,59 @@ +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(); + } + } +} diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 474d5afc..cc7ebc85 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -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 $tags * @param Collection & Selectable $visits + * @param Collection & 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 */ diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index d6f7e421..2f638c73 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -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); } } diff --git a/module/Core/src/Visit/Entity/ShortUrlVisitsCount.php b/module/Core/src/Visit/Entity/ShortUrlVisitsCount.php new file mode 100644 index 00000000..ff3580b3 --- /dev/null +++ b/module/Core/src/Visit/Entity/ShortUrlVisitsCount.php @@ -0,0 +1,19 @@ +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,