diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index 3eb43edf..eae7e8a9 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -2,9 +2,11 @@ declare(strict_types=1); +use Doctrine\ORM\Events; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\Visit\Listener\ShortUrlVisitsCountPreFlushListener; use function Shlinkio\Shlink\Core\ArrayUtils\contains; return (static function (): array { @@ -60,6 +62,11 @@ return (static function (): array { 'proxies_dir' => 'data/proxies', 'load_mappings_using_functional_style' => true, 'default_repository_classname' => EntitySpecificationRepository::class, + 'listeners' => [ + Events::preFlush => [ + ShortUrlVisitsCountPreFlushListener::class, + ], + ], ], 'connection' => $resolveConnection(), ], diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index d75c6bb8..7d3bf763 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -76,6 +76,7 @@ return [ EntityRepositoryFactory::class, Visit\Entity\Visit::class, ], + Visit\Listener\ShortUrlVisitsCountPreFlushListener::class => InvokableFactory::class, Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, Util\RedirectResponseHelper::class => ConfigAbstractFactory::class, diff --git a/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php b/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php new file mode 100644 index 00000000..6a006e54 --- /dev/null +++ b/module/Core/src/Visit/Listener/ShortUrlVisitsCountPreFlushListener.php @@ -0,0 +1,146 @@ +getObjectManager(); + $entitiesToBeCreated = $em->getUnitOfWork()->getScheduledEntityInsertions(); + + foreach ($entitiesToBeCreated as $entity) { + $this->trackVisitCount($em, $entity); + } + } + + /** + * @throws Exception + */ + private function trackVisitCount(EntityManagerInterface $em, object $entity): void + { + // This is not a non-orphan visit + if (!$entity instanceof Visit || $entity->shortUrl === null) { + return; + } + $visit = $entity; + + // The short URL is not persisted yet + $shortUrlId = $visit->shortUrl->getId(); + if ($shortUrlId === null || $shortUrlId === '') { + return; + } + + $isBot = $visit->potentialBot; + $conn = $em->getConnection(); + $platformClass = $conn->getDatabasePlatform(); + + match ($platformClass::class) { + PostgreSQLPlatform::class => $this->incrementForPostgres($conn, $shortUrlId, $isBot), + SQLitePlatform::class, SQLServerPlatform::class => $this->incrementForOthers($conn, $shortUrlId, $isBot), + default => $this->incrementForMySQL($conn, $shortUrlId, $isBot), + }; + } + + /** + * @throws Exception + */ + private function incrementForMySQL(Connection $conn, string $shortUrlId, bool $potentialBot): void + { + $this->incrementWithPreparedStatement($conn, $shortUrlId, $potentialBot, <<incrementWithPreparedStatement($conn, $shortUrlId, $potentialBot, <<prepare($query); + $statement->bindValue('short_url_id', $shortUrlId); + $statement->bindValue('potential_bot', $potentialBot ? 1 : 0); + $statement->executeStatement(); + } + + /** + * @throws Exception + */ + private function incrementForOthers(Connection $conn, string $shortUrlId, bool $potentialBot): void + { + $slotId = rand(1, 100); + + // 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('short_url_visits_counts') + ->where($qb->expr()->and( + $qb->expr()->eq('short_url_id', ':short_url_id'), + $qb->expr()->eq('potential_bot', ':potential_bot'), + $qb->expr()->eq('slot_id', ':slot_id'), + )) + ->setParameter('short_url_id', $shortUrlId) + ->setParameter('potential_bot', $potentialBot) + ->setParameter('slot_id', $slotId) + ->forUpdate() + ->setMaxResults(1); + + $resultSet = $qb->executeQuery()->fetchOne(); + $writeQb = ! $resultSet + ? $conn->createQueryBuilder() + ->insert('short_url_visits_counts') + ->values([ + 'short_url_id' => ':short_url_id', + 'potential_bot' => ':potential_bot', + 'slot_id' => ':slot_id', + ]) + : $conn->createQueryBuilder() + ->update('short_url_visits_counts') + ->set('count', 'count + 1') + ->where($qb->expr()->and( + $qb->expr()->eq('short_url_id', ':short_url_id'), + $qb->expr()->eq('potential_bot', ':potential_bot'), + $qb->expr()->eq('slot_id', ':slot_id'), + )); + + $writeQb->setParameter('short_url_id', $shortUrlId) + ->setParameter('potential_bot', $potentialBot) + ->setParameter('slot_id', $slotId) + ->executeStatement(); + } +}