Private
Public Access
chg: dev: create the UserStats entity what is a Materialized View to store Profile stats for every user - & massive ProfileController refactor #8
This commit is contained in:
@@ -14,12 +14,7 @@ use App\Entity\PlayedGame;
|
||||
use App\Entity\User;
|
||||
use DateTime;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\NonUniqueResultException;
|
||||
use Doctrine\ORM\NoResultException;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Class PlayedGameRepository
|
||||
@@ -36,319 +31,15 @@ use RuntimeException;
|
||||
* @method PlayedGame[] findAll()
|
||||
* @method PlayedGame[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
* @method PlayedGame|null findOneByGameAssoc(mixed $gameAssoc)
|
||||
* @method PlayedGame|null findOneByUuid(\Symfony\Component\Uid\Uuid $uuid)
|
||||
*/
|
||||
class PlayedGameRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PlayedGame::class);
|
||||
}
|
||||
|
||||
public function countFinishedForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
try {
|
||||
return (int)$qb
|
||||
->select('COUNT(g.id)')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNotNull('g.redPoints'),
|
||||
$qb->expr()->isNotNull('g.resign'),
|
||||
),
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
} catch (NoResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly no result found when counting finished games for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
} catch (NonUniqueResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly multiple results found when counting finished games for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function countWinsForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
try {
|
||||
return (int)$qb
|
||||
->select('COUNT(g.id)')
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->gt('g.redPoints', 'g.bluePoints'),
|
||||
$qb->expr()->isNull('g.resign'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->gt('g.bluePoints', 'g.redPoints'),
|
||||
$qb->expr()->isNull('g.resign'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.resign', ':blue'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->eq('g.resign', ':red'),
|
||||
),
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->setParameter('blue', 'blue')
|
||||
->setParameter('red', 'red')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
} catch (NoResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly no result found when counting wins for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
} catch (NonUniqueResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly multiple results found when counting wins for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function countLossesForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
try {
|
||||
return (int)$qb
|
||||
->select('COUNT(g.id)')
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->gt('g.bluePoints', 'g.redPoints'),
|
||||
$qb->expr()->isNull('g.resign'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->gt('g.redPoints', 'g.bluePoints'),
|
||||
$qb->expr()->isNull('g.resign'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.resign', ':red'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->eq('g.resign', ':blue'),
|
||||
),
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->setParameter('red', 'red')
|
||||
->setParameter('blue', 'blue')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
} catch (NoResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly no result found when counting losses for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
} catch (NonUniqueResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly multiple results found when counting losses for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function countBombsForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
try {
|
||||
return (int)$qb
|
||||
->select('COUNT(g.id)')
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.redExplodedBomb', ':true'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->eq('g.blueExplodedBomb', ':true'),
|
||||
),
|
||||
))
|
||||
->setParameter('true', true, Types::BOOLEAN)
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
} catch (NoResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly no result found when counting bombs for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
} catch (NonUniqueResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
throw new RuntimeException(
|
||||
"Unexpectedly multiple results found when counting bombs for user: {$user->getUsername()}",
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function countDrawsForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
return (int)$qb
|
||||
->select('COUNT(g.id)')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
),
|
||||
$qb->expr()->isNotNull('g.redPoints'),
|
||||
$qb->expr()->isNotNull('g.bluePoints'),
|
||||
$qb->expr()->isNull('g.resign'),
|
||||
'g.redPoints = g.bluePoints',
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function findTotalMinesForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
return (int)$qb
|
||||
->select('COALESCE(SUM(CASE WHEN g.red = :u THEN g.redPoints ELSE g.bluePoints END), 0)')
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function findAvgScoreForUser(User $user): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
/** @var array{totalPts: int|string|null, totalGames: int|string} $row */
|
||||
$row = $qb
|
||||
->select('SUM(CASE WHEN g.red = :u THEN g.redPoints ELSE g.bluePoints END) AS totalPts')
|
||||
->addSelect('COUNT(g.id) AS totalGames')
|
||||
->where($qb->expr()->orX(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->isNotNull('g.redPoints'),
|
||||
),
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
$qb->expr()->isNotNull('g.bluePoints'),
|
||||
),
|
||||
))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleResult();
|
||||
|
||||
if ((int)$row['totalGames'] === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)round((float)$row['totalPts'] / (int)$row['totalGames']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates bonus points and bonus stats across all finished games for a user.
|
||||
*/
|
||||
public function findBonusStatsForUser(User $user): array
|
||||
{
|
||||
$userId = $user->id;
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
$qb->where($qb->expr()->orX(
|
||||
$qb->expr()->eq('g.red', ':u'),
|
||||
$qb->expr()->eq('g.blue', ':u'),
|
||||
))->setParameter('u', $user);
|
||||
|
||||
/** @var PlayedGame[] $games */
|
||||
$games = $qb->getQuery()->getResult();
|
||||
|
||||
$totalBonusPoints = 0.0;
|
||||
$bestChain = 0;
|
||||
$totalBlindHits = 0;
|
||||
$totalEdgeMines = 0;
|
||||
$gameCount = 0;
|
||||
|
||||
foreach ($games as $game) {
|
||||
$isRed = $game->red?->id === $userId;
|
||||
$totalBonusPoints += (float)(($isRed ? $game->redBonusPoints : $game->blueBonusPoints) ?? 0.0);
|
||||
|
||||
$stats = ($isRed ? $game->redBonusStats : $game->blueBonusStats) ?? [];
|
||||
$bestChain = max($bestChain, (int)($stats['chainBest'] ?? 0));
|
||||
$totalBlindHits += (int)($stats['blindHits'] ?? 0);
|
||||
$totalEdgeMines += (int)($stats['edgeMines'] ?? 0);
|
||||
$gameCount++;
|
||||
}
|
||||
|
||||
return [
|
||||
'totalBonusPoints' => round($totalBonusPoints, 1),
|
||||
'avgBonusPoints' => 0 < $gameCount ? round($totalBonusPoints / $gameCount, 1) : 0.0,
|
||||
'bestChain' => $bestChain,
|
||||
'totalBlindHits' => $totalBlindHits,
|
||||
'totalEdgeMines' => $totalEdgeMines,
|
||||
];
|
||||
}
|
||||
|
||||
public function findBestScoreForUser(User $user): int
|
||||
{
|
||||
try {
|
||||
$qbRed = $this->createQueryBuilder('g');
|
||||
$maxRed = (int)$qbRed
|
||||
->select('MAX(g.redPoints)')
|
||||
->where($qbRed->expr()->eq('g.red', ':u'))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$qbBlue = $this->createQueryBuilder('g');
|
||||
$maxBlue = (int)$qbBlue
|
||||
->select('MAX(g.bluePoints)')
|
||||
->where($qbBlue->expr()->eq('g.blue', ':u'))
|
||||
->setParameter('u', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
return max($maxRed, $maxBlue);
|
||||
} catch (NoResultException|NonUniqueResultException $e) {
|
||||
$this->logger->error($e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function findFinishedForUserSince(User $user, DateTime $since): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('g');
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of the SplendidBear Websites' projects.
|
||||
*
|
||||
* Copyright (c) 2026 @ www.splendidbear.org
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\UserStats;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Exception;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Class UserStatsRepository
|
||||
*
|
||||
* @package App\Repository
|
||||
* @author Lang <https://www.splendidbear.org>
|
||||
* @category Class
|
||||
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||
* @link www.splendidbear.org
|
||||
* @since 2026. 04. 20.
|
||||
*
|
||||
* @method UserStats|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method UserStats|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method UserStats[] findAll()
|
||||
* @method UserStats[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class UserStatsRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, UserStats::class);
|
||||
}
|
||||
|
||||
public function findByUserId(int $userId): ?UserStats
|
||||
{
|
||||
return $this->find($userId);
|
||||
}
|
||||
|
||||
public function refreshMaterializedView(): void
|
||||
{
|
||||
try {
|
||||
$this
|
||||
->getEntityManager()
|
||||
->getConnection()
|
||||
->executeStatement('REFRESH MATERIALIZED VIEW CONCURRENTLY user_stats');
|
||||
} catch (Exception $e) {
|
||||
throw new RuntimeException("Failed to refresh materialized view: {$e->getMessage()}", 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user