Private
Public Access
1
0

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:
2026-04-20 21:08:15 +02:00
parent 6a5ba84b5e
commit 2ec37a802b
7 changed files with 341 additions and 35 deletions

View File

@@ -16,6 +16,7 @@ doctrine:
auto_mapping: true auto_mapping: true
schema_ignore_classes: schema_ignore_classes:
- App\Entity\UserStats - App\Entity\UserStats
- App\Entity\RecentBattle
mappings: mappings:
App: App:
is_bundle: false is_bundle: false

View File

@@ -16,9 +16,10 @@ use App\Dto\ProfileGameDto;
use App\Dto\ProfileGameDtoFactory; use App\Dto\ProfileGameDtoFactory;
use App\Dto\ProfileStatsDto; use App\Dto\ProfileStatsDto;
use App\Dto\ProfileViewDto; use App\Dto\ProfileViewDto;
use App\Entity\PlayedGame;
use App\Entity\User; use App\Entity\User;
use App\Entity\RecentBattle;
use App\Repository\PlayedGameRepository; use App\Repository\PlayedGameRepository;
use App\Repository\RecentBattleRepository;
use App\Repository\UserStatsRepository; use App\Repository\UserStatsRepository;
use App\Service\BattleCardGenerator; use App\Service\BattleCardGenerator;
use App\Service\WebAuthnService; use App\Service\WebAuthnService;
@@ -59,6 +60,7 @@ class ProfileController extends AbstractController
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
private readonly PlayedGameRepository $repo, private readonly PlayedGameRepository $repo,
private readonly UserStatsRepository $userStatsRepo, private readonly UserStatsRepository $userStatsRepo,
private readonly RecentBattleRepository $recentBattleRepo,
private readonly WebAuthnService $webAuthnService, private readonly WebAuthnService $webAuthnService,
private readonly ProfileGameDtoFactory $profileGameDtoFactory, private readonly ProfileGameDtoFactory $profileGameDtoFactory,
private readonly ProfileChartDataFactory $profileChartDataFactory, private readonly ProfileChartDataFactory $profileChartDataFactory,
@@ -74,10 +76,10 @@ class ProfileController extends AbstractController
$userId = $user->id; $userId = $user->id;
$stats = ProfileStatsDto::fromUserStats($this->userStatsRepo->findByUserId($userId)); $stats = ProfileStatsDto::fromUserStats($this->userStatsRepo->findByUserId($userId));
$recent = $this->repo->findRecentFinishedForUser($user, 30); $recent = $this->recentBattleRepo->findRecentForUser($userId, 30);
$gamesData = array_map( $gamesData = array_map(
fn(PlayedGame $game): ProfileGameDto => $this->profileGameDtoFactory->create($game, $userId), fn(RecentBattle $battle): ProfileGameDto => $this->profileGameDtoFactory->createFromRecentBattle($battle),
$recent, $recent,
); );

View File

@@ -11,6 +11,7 @@
namespace App\Dto; namespace App\Dto;
use App\Entity\PlayedGame; use App\Entity\PlayedGame;
use App\Entity\RecentBattle;
use Liip\ImagineBundle\Imagine\Cache\CacheManager; use Liip\ImagineBundle\Imagine\Cache\CacheManager;
/** /**
@@ -91,4 +92,43 @@ final readonly class ProfileGameDtoFactory
return 'draw'; 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
View 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;
}

View 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');
}
}

View 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);
}
}
}

View File

@@ -127,32 +127,16 @@
</div> </div>
<div class="profile-games" data-batch-size="5"> <div class="profile-games" data-batch-size="5">
{% for game in recent %} {% for game in recent %}
{% set is_red = game.red and game.red.id == app.user.id %} {% set is_red = game.isRed %}
{% set my_points = is_red ? game.redPoints : game.bluePoints %} {% set my_points = is_red ? game.redPoints : game.bluePoints %}
{% set opp_points = is_red ? game.bluePoints : game.redPoints %} {% set opp_points = is_red ? game.bluePoints : game.redPoints %}
{% set opp = is_red ? game.blue : game.red %} {% set opp_name = is_red ? game.blueName : game.redName %}
{% set opp_anon = is_red ? game.blueAnon : game.redAnon %} {% set result = game.result %}
{% set result = 'draw' %} {% set is_finished = game.resign is not null
{% set is_finished = false %} or (my_points is not null and opp_points is not null
{% set is_anonymous = not opp and opp_anon %} and (my_points > 25 or opp_points > 25)) %}
{% if game.resign == (is_red ? 'red' : 'blue') %} {% set is_anonymous = game.oppIsGuest %}
{% set result = 'loss' %}
{% set is_finished = true %}
{% elseif game.resign == (is_red ? 'blue' : 'red') %}
{% set result = 'win' %}
{% set is_finished = true %}
{% elseif my_points is not null and opp_points is not null %}
{% if my_points > opp_points %}
{% set result = 'win' %}
{% set is_finished = (my_points > 25 or opp_points > 25) %}
{% elseif my_points < opp_points %}
{% set result = 'loss' %}
{% set is_finished = (my_points > 25 or opp_points > 25) %}
{% else %}
{% set is_finished = (my_points > 25 or opp_points > 25) %}
{% endif %}
{% endif %}
<div class="profile-game profile-game--{{ result }}{% if not is_finished and not is_anonymous %} profile-game--ongoing{% elseif is_anonymous %} profile-game--abandoned{% endif %}{% if loop.index0 >= 5 %} profile-game--hidden{% endif %}" data-game-index="{{ loop.index0 }}"> <div class="profile-game profile-game--{{ result }}{% if not is_finished and not is_anonymous %} profile-game--ongoing{% elseif is_anonymous %} profile-game--abandoned{% endif %}{% if loop.index0 >= 5 %} profile-game--hidden{% endif %}" data-game-index="{{ loop.index0 }}">
<span class="profile-game__badge"> <span class="profile-game__badge">
@@ -168,15 +152,7 @@
{{ my_points ?? '—' }} : {{ opp_points ?? '—' }} {{ my_points ?? '—' }} : {{ opp_points ?? '—' }}
</span> </span>
<span class="profile-game__vs">vs</span> <span class="profile-game__vs">vs</span>
<span class="profile-game__opponent"> <span class="profile-game__opponent">{{ opp_name }}</span>
{% if opp %}
{{ opp.username }}
{% elseif opp_anon %}
{{ opp_anon.userName }}
{% else %}
Guest
{% endif %}
</span>
<span class="profile-game__color"> <span class="profile-game__color">
<i class="fas fa-circle" style="color: {{ is_red ? '#c0392b' : '#2980b9' }}"></i> <i class="fas fa-circle" style="color: {{ is_red ? '#c0392b' : '#2980b9' }}"></i>
</span> </span>