chg: dev: add RecentBattle entity that is a Materialized View to speed up the view - and further refactor on ProfileController #8
This commit is contained in:
@@ -16,9 +16,10 @@ use App\Dto\ProfileGameDto;
|
||||
use App\Dto\ProfileGameDtoFactory;
|
||||
use App\Dto\ProfileStatsDto;
|
||||
use App\Dto\ProfileViewDto;
|
||||
use App\Entity\PlayedGame;
|
||||
use App\Entity\User;
|
||||
use App\Entity\RecentBattle;
|
||||
use App\Repository\PlayedGameRepository;
|
||||
use App\Repository\RecentBattleRepository;
|
||||
use App\Repository\UserStatsRepository;
|
||||
use App\Service\BattleCardGenerator;
|
||||
use App\Service\WebAuthnService;
|
||||
@@ -59,6 +60,7 @@ class ProfileController extends AbstractController
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly PlayedGameRepository $repo,
|
||||
private readonly UserStatsRepository $userStatsRepo,
|
||||
private readonly RecentBattleRepository $recentBattleRepo,
|
||||
private readonly WebAuthnService $webAuthnService,
|
||||
private readonly ProfileGameDtoFactory $profileGameDtoFactory,
|
||||
private readonly ProfileChartDataFactory $profileChartDataFactory,
|
||||
@@ -74,10 +76,10 @@ class ProfileController extends AbstractController
|
||||
|
||||
$userId = $user->id;
|
||||
$stats = ProfileStatsDto::fromUserStats($this->userStatsRepo->findByUserId($userId));
|
||||
$recent = $this->repo->findRecentFinishedForUser($user, 30);
|
||||
$recent = $this->recentBattleRepo->findRecentForUser($userId, 30);
|
||||
|
||||
$gamesData = array_map(
|
||||
fn(PlayedGame $game): ProfileGameDto => $this->profileGameDtoFactory->create($game, $userId),
|
||||
fn(RecentBattle $battle): ProfileGameDto => $this->profileGameDtoFactory->createFromRecentBattle($battle),
|
||||
$recent,
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
namespace App\Dto;
|
||||
|
||||
use App\Entity\PlayedGame;
|
||||
use App\Entity\RecentBattle;
|
||||
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
||||
|
||||
/**
|
||||
@@ -91,4 +92,43 @@ final readonly class ProfileGameDtoFactory
|
||||
|
||||
return 'draw';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ProfileGameDto directly from the recent_battles materialized view row.
|
||||
* Avatar paths are still resolved via LiipImagine (they are stored as storage paths).
|
||||
*/
|
||||
public function createFromRecentBattle(RecentBattle $battle): ProfileGameDto
|
||||
{
|
||||
$myPts = $battle->isRed ? $battle->redPoints : $battle->bluePoints;
|
||||
$oppPts = $battle->isRed ? $battle->bluePoints : $battle->redPoints;
|
||||
|
||||
return new ProfileGameDto(
|
||||
id: $battle->gameId,
|
||||
uuid: $battle->uuid,
|
||||
redName: $battle->redName,
|
||||
blueName: $battle->blueName,
|
||||
redAvatar: $battle->redAvatarPath
|
||||
? $this->cacheManager->generateUrl($battle->redAvatarPath, 'avatar_thumb')
|
||||
: null,
|
||||
blueAvatar: $battle->blueAvatarPath
|
||||
? $this->cacheManager->generateUrl($battle->blueAvatarPath, 'avatar_thumb')
|
||||
: null,
|
||||
redPoints: $battle->redPoints,
|
||||
bluePoints: $battle->bluePoints,
|
||||
redExplodedBomb: $battle->redExplodedBomb,
|
||||
blueExplodedBomb: $battle->blueExplodedBomb,
|
||||
resign: $battle->resign,
|
||||
created: $battle->created?->format('Y-m-d H:i'),
|
||||
date: $battle->updated?->format('Y-m-d H:i'),
|
||||
isRed: $battle->isRed,
|
||||
result: $battle->result,
|
||||
myPoints: $myPts,
|
||||
oppPoints: $oppPts,
|
||||
redBonusPoints: $battle->redBonusPoints,
|
||||
blueBonusPoints: $battle->blueBonusPoints,
|
||||
redBonusStats: $battle->redBonusStats,
|
||||
blueBonusStats: $battle->blueBonusStats,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
100
src/Entity/RecentBattle.php
Normal file
100
src/Entity/RecentBattle.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?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\Entity;
|
||||
|
||||
use App\Repository\RecentBattleRepository;
|
||||
use DateTime;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\Column;
|
||||
use Doctrine\ORM\Mapping\Entity;
|
||||
use Doctrine\ORM\Mapping\Id;
|
||||
use Doctrine\ORM\Mapping\Table;
|
||||
|
||||
/**
|
||||
* Class RecentBattle
|
||||
*
|
||||
* Read-only entity mapped to the recent_battles materialized view.
|
||||
* Each row represents one game from the perspective of a single user.
|
||||
*
|
||||
* @package App\Entity
|
||||
* @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.
|
||||
*/
|
||||
#[Entity(repositoryClass: RecentBattleRepository::class, readOnly: true), Table(name: 'recent_battles')]
|
||||
class RecentBattle
|
||||
{
|
||||
/** Composite PK: (user_id, game_id) — mapped via the unique index. */
|
||||
#[Id, Column]
|
||||
public int $userId = 0;
|
||||
|
||||
#[Id, Column]
|
||||
public int $gameId = 0;
|
||||
|
||||
#[Column(name: 'uuid')]
|
||||
public string $uuid = '';
|
||||
|
||||
#[Column]
|
||||
public bool $isRed = false;
|
||||
|
||||
#[Column]
|
||||
public string $redName = '';
|
||||
|
||||
#[Column]
|
||||
public string $blueName = '';
|
||||
|
||||
#[Column(nullable: true)]
|
||||
public ?string $redAvatarPath = null;
|
||||
|
||||
#[Column(nullable: true)]
|
||||
public ?string $blueAvatarPath = null;
|
||||
|
||||
#[Column(nullable: true)]
|
||||
public ?int $redPoints = null;
|
||||
|
||||
#[Column(nullable: true)]
|
||||
public ?int $bluePoints = null;
|
||||
|
||||
#[Column(nullable: true)]
|
||||
public ?bool $redExplodedBomb = null;
|
||||
|
||||
#[Column(nullable: true)]
|
||||
public ?bool $blueExplodedBomb = null;
|
||||
|
||||
#[Column(nullable: true)]
|
||||
public ?string $resign = null;
|
||||
|
||||
#[Column]
|
||||
public float $redBonusPoints = 0.0;
|
||||
|
||||
#[Column]
|
||||
public float $blueBonusPoints = 0.0;
|
||||
|
||||
#[Column(type: Types::JSON)]
|
||||
public array $redBonusStats = [];
|
||||
|
||||
#[Column(type: Types::JSON)]
|
||||
public array $blueBonusStats = [];
|
||||
|
||||
#[Column]
|
||||
public string $result = 'draw';
|
||||
|
||||
#[Column]
|
||||
public bool $oppIsGuest = false;
|
||||
|
||||
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
public ?DateTime $created = null;
|
||||
|
||||
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
public ?DateTime $updated = null;
|
||||
}
|
||||
124
src/Migrations/2026/04/Version20260420130000.php
Normal file
124
src/Migrations/2026/04/Version20260420130000.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?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\Migrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Class Version20260420130000
|
||||
*
|
||||
* Creates recent_battles materialized view for ProfileController optimization.
|
||||
*
|
||||
* @package App\Migrations
|
||||
* @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.
|
||||
*/
|
||||
final class Version20260420130000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create recent_battles materialized view for profile recent games list.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('
|
||||
CREATE MATERIALIZED VIEW recent_battles AS
|
||||
SELECT
|
||||
pg.id AS game_id,
|
||||
pg.uuid::text AS uuid,
|
||||
u.id AS user_id,
|
||||
CASE WHEN pg.red_id = u.id THEN true ELSE false END AS is_red,
|
||||
|
||||
COALESCE(ru.username, ra.user_name, \'Guest\') AS red_name,
|
||||
COALESCE(bu.username, ba.user_name, \'Guest\') AS blue_name,
|
||||
|
||||
ru.avatar_path AS red_avatar_path,
|
||||
bu.avatar_path AS blue_avatar_path,
|
||||
|
||||
pg.red_points,
|
||||
pg.blue_points,
|
||||
pg.red_exploded_bomb,
|
||||
pg.blue_exploded_bomb,
|
||||
pg.resign,
|
||||
|
||||
COALESCE(pg.red_bonus_points, 0.0) AS red_bonus_points,
|
||||
COALESCE(pg.blue_bonus_points, 0.0) AS blue_bonus_points,
|
||||
COALESCE(pg.red_bonus_stats, \'[]\'::json) AS red_bonus_stats,
|
||||
COALESCE(pg.blue_bonus_stats, \'[]\'::json) AS blue_bonus_stats,
|
||||
|
||||
CASE
|
||||
WHEN pg.red_id = u.id AND pg.resign = \'red\' THEN \'loss\'
|
||||
WHEN pg.blue_id = u.id AND pg.resign = \'blue\' THEN \'loss\'
|
||||
WHEN pg.red_id = u.id AND pg.resign = \'blue\' THEN \'win\'
|
||||
WHEN pg.blue_id = u.id AND pg.resign = \'red\' THEN \'win\'
|
||||
WHEN pg.red_id = u.id AND pg.red_points IS NOT NULL AND pg.blue_points IS NOT NULL
|
||||
AND pg.red_points > pg.blue_points THEN \'win\'
|
||||
WHEN pg.blue_id = u.id AND pg.blue_points IS NOT NULL AND pg.red_points IS NOT NULL
|
||||
AND pg.blue_points > pg.red_points THEN \'win\'
|
||||
WHEN pg.red_id = u.id AND pg.red_points IS NOT NULL AND pg.blue_points IS NOT NULL
|
||||
AND pg.red_points < pg.blue_points THEN \'loss\'
|
||||
WHEN pg.blue_id = u.id AND pg.blue_points IS NOT NULL AND pg.red_points IS NOT NULL
|
||||
AND pg.blue_points < pg.red_points THEN \'loss\'
|
||||
ELSE \'draw\'
|
||||
END AS result,
|
||||
|
||||
-- Whether the opponent in this game is an anonymous guest (no app_user account)
|
||||
CASE
|
||||
WHEN pg.red_id = u.id AND pg.blue_id IS NULL THEN true
|
||||
WHEN pg.blue_id = u.id AND pg.red_id IS NULL THEN true
|
||||
ELSE false
|
||||
END AS opp_is_guest,
|
||||
|
||||
pg.created,
|
||||
pg.updated
|
||||
FROM app_user u
|
||||
JOIN played_game pg
|
||||
ON pg.red_id = u.id
|
||||
OR pg.blue_id = u.id
|
||||
LEFT JOIN app_user ru ON ru.id = pg.red_id
|
||||
LEFT JOIN app_user bu ON bu.id = pg.blue_id
|
||||
LEFT JOIN gamer ra ON ra.id = pg.red_anon
|
||||
LEFT JOIN gamer ba ON ba.id = pg.blue_anon
|
||||
');
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX idx_recent_battles_pk ON recent_battles (user_id, game_id)');
|
||||
$this->addSql('CREATE INDEX idx_recent_battles_user_upd ON recent_battles (user_id, updated DESC)');
|
||||
|
||||
$this->addSql('
|
||||
CREATE OR REPLACE FUNCTION refresh_recent_battles()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY recent_battles;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
');
|
||||
|
||||
$this->addSql('
|
||||
CREATE TRIGGER trigger_refresh_recent_battles
|
||||
AFTER INSERT OR UPDATE OR DELETE ON played_game
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION refresh_recent_battles()
|
||||
');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TRIGGER IF EXISTS trigger_refresh_recent_battles ON played_game');
|
||||
$this->addSql('DROP FUNCTION IF EXISTS refresh_recent_battles()');
|
||||
$this->addSql('DROP MATERIALIZED VIEW IF EXISTS recent_battles');
|
||||
}
|
||||
}
|
||||
63
src/Repository/RecentBattleRepository.php
Normal file
63
src/Repository/RecentBattleRepository.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?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\RecentBattle;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Exception;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Class RecentBattleRepository
|
||||
*
|
||||
* @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 RecentBattle|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method RecentBattle|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method RecentBattle[] findAll()
|
||||
* @method RecentBattle[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class RecentBattleRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, RecentBattle::class);
|
||||
}
|
||||
|
||||
public function findRecentForUser(int $userId, int $limit = 30): array
|
||||
{
|
||||
return $this->createQueryBuilder('rb')
|
||||
->where('rb.userId = :uid')
|
||||
->setParameter('uid', $userId)
|
||||
->orderBy('rb.updated', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function refreshMaterializedView(): void
|
||||
{
|
||||
try {
|
||||
$this
|
||||
->getEntityManager()
|
||||
->getConnection()
|
||||
->executeStatement('REFRESH MATERIALIZED VIEW CONCURRENTLY recent_battles');
|
||||
} catch (Exception $e) {
|
||||
throw new RuntimeException("Failed to refresh materialized view: {$e->getMessage()}", 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user