From a15b5e54965e5b191f2f8baaa3ca76bdb2711672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Fri, 2 Jan 2026 19:03:05 +0100 Subject: [PATCH 1/4] Performance: Puzzle statistics are pre-calculated --- .claude/postgres-puzzle-query-optimization.md | 856 ++++++++++++++++++ migrations/Version20260102185021.php | 43 + migrations/Version20260102185022.php | 54 ++ src/Attribute/DeleteDomainEvent.php | 19 + ...alculatePuzzleStatisticsConsoleCommand.php | 113 +++ src/Entity/PuzzleSolvingTime.php | 36 +- src/Entity/PuzzleStatistics.php | 104 +++ src/Events/PuzzleSolved.php | 1 + src/Events/PuzzleSolvingTimeDeleted.php | 21 + src/Events/PuzzleSolvingTimeModified.php | 16 + ...atePuzzleStatisticsOnSolvingTimeChange.php | 47 + src/Repository/PuzzleStatisticsRepository.php | 27 + src/Services/DomainEventsSubscriber.php | 32 + src/Services/PuzzleStatisticsCalculator.php | 83 ++ src/Value/PuzzleStatisticsData.php | 33 + src/Value/PuzzlingType.php | 21 + 16 files changed, 1504 insertions(+), 2 deletions(-) create mode 100644 .claude/postgres-puzzle-query-optimization.md create mode 100644 migrations/Version20260102185021.php create mode 100644 migrations/Version20260102185022.php create mode 100644 src/Attribute/DeleteDomainEvent.php create mode 100644 src/ConsoleCommands/RecalculatePuzzleStatisticsConsoleCommand.php create mode 100644 src/Entity/PuzzleStatistics.php create mode 100644 src/Events/PuzzleSolvingTimeDeleted.php create mode 100644 src/Events/PuzzleSolvingTimeModified.php create mode 100644 src/MessageHandler/RecalculatePuzzleStatisticsOnSolvingTimeChange.php create mode 100644 src/Repository/PuzzleStatisticsRepository.php create mode 100644 src/Services/PuzzleStatisticsCalculator.php create mode 100644 src/Value/PuzzleStatisticsData.php create mode 100644 src/Value/PuzzlingType.php diff --git a/.claude/postgres-puzzle-query-optimization.md b/.claude/postgres-puzzle-query-optimization.md new file mode 100644 index 00000000..e49146c2 --- /dev/null +++ b/.claude/postgres-puzzle-query-optimization.md @@ -0,0 +1,856 @@ +# PostgreSQL Query Optimization: Puzzle Search with Solving Statistics + +## Problem Summary + +A search query on `myspeedpuzzling.com` takes ~2 seconds due to aggregating 270k `puzzle_solving_time` rows for sorting by `solved_times`. + +### Table Sizes +- `puzzle`: 25k rows +- `puzzle_solving_time`: 270k rows +- `manufacturer`: 1k rows + +### Core Issue +The query must aggregate ALL matching puzzles to sort by `solved_times` before applying LIMIT. Additionally, `json_array_length(team->'puzzlers')` is computed repeatedly on 270k rows. + +--- + +## Solution Overview + +1. **Add `players_count` + `puzzling_type` to `puzzle_solving_time`** - Avoid JSON parsing +2. **Create separate `PuzzleStatistics` entity** - Denormalized stats in dedicated table +3. **Domain events + hourly cron** - Keep stats in sync +4. **Trigram indexes** - Fast ILIKE searches (no custom Postgres functions needed) + +--- + +## Step 1: Extend `puzzle_solving_time` Table + +### 1.1 Database Migration + +```sql +-- Add players_count and puzzling_type columns +ALTER TABLE puzzle_solving_time + ADD COLUMN players_count smallint NOT NULL DEFAULT 1, + ADD COLUMN puzzling_type varchar(10) NOT NULL DEFAULT 'solo'; + +-- Populate from existing data +UPDATE puzzle_solving_time +SET + players_count = CASE + WHEN team IS NULL THEN 1 + ELSE json_array_length(team->'puzzlers') + END, + puzzling_type = CASE + WHEN team IS NULL THEN 'solo' + WHEN json_array_length(team->'puzzlers') = 2 THEN 'duo' + ELSE 'team' + END; + +-- Index for filtering +CREATE INDEX idx_pst_puzzling_type ON puzzle_solving_time (puzzling_type); +CREATE INDEX idx_pst_puzzle_type ON puzzle_solving_time (puzzle_id, puzzling_type); +``` + +### 1.2 PuzzlingType Enum + +```php +namespace App\Enum; + +enum PuzzlingType: string +{ + case Solo = 'solo'; + case Duo = 'duo'; + case Team = 'team'; + + public static function fromPlayersCount(int $count): self + { + return match (true) { + $count === 1 => self::Solo, + $count === 2 => self::Duo, + default => self::Team, + }; + } +} +``` + +### 1.3 Updated PuzzleSolvingTime Entity + +```php +namespace App\Entity; + +use App\Enum\PuzzlingType; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; + +#[ORM\Entity] +#[ORM\Table(name: 'puzzle_solving_time')] +class PuzzleSolvingTime +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + public int $id; + + #[ORM\ManyToOne(targetEntity: Puzzle::class)] + #[ORM\JoinColumn(nullable: false)] + public Puzzle $puzzle; + + #[ORM\Column] + public int $secondsToSolve; + + #[ORM\Column(type: PuzzlersGroupDoctrineType::NAME, nullable: true)] + public null|PuzzlersGroup $team; + + #[ORM\Column(type: 'smallint')] + #[Assert\Positive] + public int $playersCount; + + #[ORM\Column(type: 'string', enumType: PuzzlingType::class)] + public PuzzlingType $puzzlingType; + + public function __construct( + Puzzle $puzzle, + int $secondsToSolve, + null|PuzzlersGroup $team, + ) { + $this->puzzle = $puzzle; + $this->secondsToSolve = $secondsToSolve; + $this->team = $team; + $this->playersCount = $team === null ? 1 : count($team->puzzlers); + $this->puzzlingType = PuzzlingType::fromPlayersCount($this->playersCount); + } +} +``` + +--- + +## Step 2: Create `PuzzleStatistics` Entity + +### 2.1 Database Migration + +```sql +CREATE TABLE puzzle_statistics ( + puzzle_id int PRIMARY KEY REFERENCES puzzle(id) ON DELETE CASCADE, + + -- Total + solved_times_count int NOT NULL DEFAULT 0, + fastest_time int DEFAULT NULL, + average_time int DEFAULT NULL, + slowest_time int DEFAULT NULL, + + -- Solo + solved_times_solo_count int NOT NULL DEFAULT 0, + fastest_time_solo int DEFAULT NULL, + average_time_solo int DEFAULT NULL, + slowest_time_solo int DEFAULT NULL, + + -- Duo + solved_times_duo_count int NOT NULL DEFAULT 0, + fastest_time_duo int DEFAULT NULL, + average_time_duo int DEFAULT NULL, + slowest_time_duo int DEFAULT NULL, + + -- Team + solved_times_team_count int NOT NULL DEFAULT 0, + fastest_time_team int DEFAULT NULL, + average_time_team int DEFAULT NULL, + slowest_time_team int DEFAULT NULL +); + +-- Indexes for sorting +CREATE INDEX idx_ps_solved_count ON puzzle_statistics (solved_times_count); +CREATE INDEX idx_ps_solved_solo_count ON puzzle_statistics (solved_times_solo_count); +CREATE INDEX idx_ps_solved_duo_count ON puzzle_statistics (solved_times_duo_count); +CREATE INDEX idx_ps_solved_team_count ON puzzle_statistics (solved_times_team_count); +CREATE INDEX idx_ps_fastest_time ON puzzle_statistics (fastest_time); +CREATE INDEX idx_ps_fastest_time_solo ON puzzle_statistics (fastest_time_solo); +``` + +### 2.2 PuzzleStatistics Entity + +```php +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints\Immutable; + +#[ORM\Entity] +#[ORM\Table(name: 'puzzle_statistics')] +class PuzzleStatistics +{ + #[ORM\Id] + #[ORM\OneToOne(targetEntity: Puzzle::class)] + #[ORM\JoinColumn(name: 'puzzle_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[Immutable] + public Puzzle $puzzle; + + // Total + #[ORM\Column(options: ['default' => 0])] + public int $solvedTimesCount = 0; + + #[ORM\Column(nullable: true)] + public ?int $fastestTime = null; + + #[ORM\Column(nullable: true)] + public ?int $averageTime = null; + + #[ORM\Column(nullable: true)] + public ?int $slowestTime = null; + + // Solo + #[ORM\Column(options: ['default' => 0])] + public int $solvedTimesSoloCount = 0; + + #[ORM\Column(nullable: true)] + public ?int $fastestTimeSolo = null; + + #[ORM\Column(nullable: true)] + public ?int $averageTimeSolo = null; + + #[ORM\Column(nullable: true)] + public ?int $slowestTimeSolo = null; + + // Duo + #[ORM\Column(options: ['default' => 0])] + public int $solvedTimesDuoCount = 0; + + #[ORM\Column(nullable: true)] + public ?int $fastestTimeDuo = null; + + #[ORM\Column(nullable: true)] + public ?int $averageTimeDuo = null; + + #[ORM\Column(nullable: true)] + public ?int $slowestTimeDuo = null; + + // Team + #[ORM\Column(options: ['default' => 0])] + public int $solvedTimesTeamCount = 0; + + #[ORM\Column(nullable: true)] + public ?int $fastestTimeTeam = null; + + #[ORM\Column(nullable: true)] + public ?int $averageTimeTeam = null; + + #[ORM\Column(nullable: true)] + public ?int $slowestTimeTeam = null; + + public function __construct(Puzzle $puzzle) + { + $this->puzzle = $puzzle; + } + + public function update(PuzzleStatisticsData $data): void + { + $this->solvedTimesCount = $data->totalCount; + $this->fastestTime = $data->fastestTime; + $this->averageTime = $data->averageTime; + $this->slowestTime = $data->slowestTime; + + $this->solvedTimesSoloCount = $data->soloCount; + $this->fastestTimeSolo = $data->fastestTimeSolo; + $this->averageTimeSolo = $data->averageTimeSolo; + $this->slowestTimeSolo = $data->slowestTimeSolo; + + $this->solvedTimesDuoCount = $data->duoCount; + $this->fastestTimeDuo = $data->fastestTimeDuo; + $this->averageTimeDuo = $data->averageTimeDuo; + $this->slowestTimeDuo = $data->slowestTimeDuo; + + $this->solvedTimesTeamCount = $data->teamCount; + $this->fastestTimeTeam = $data->fastestTimeTeam; + $this->averageTimeTeam = $data->averageTimeTeam; + $this->slowestTimeTeam = $data->slowestTimeTeam; + } +} +``` + +### 2.3 PuzzleStatisticsData DTO + +```php +namespace App\DTO; + +readonly class PuzzleStatisticsData +{ + public function __construct( + public int $totalCount = 0, + public ?int $fastestTime = null, + public ?int $averageTime = null, + public ?int $slowestTime = null, + + public int $soloCount = 0, + public ?int $fastestTimeSolo = null, + public ?int $averageTimeSolo = null, + public ?int $slowestTimeSolo = null, + + public int $duoCount = 0, + public ?int $fastestTimeDuo = null, + public ?int $averageTimeDuo = null, + public ?int $slowestTimeDuo = null, + + public int $teamCount = 0, + public ?int $fastestTimeTeam = null, + public ?int $averageTimeTeam = null, + public ?int $slowestTimeTeam = null, + ) {} + + public static function empty(): self + { + return new self(); + } +} +``` + +### 2.4 Add Relation to Puzzle Entity + +```php +// In Puzzle entity, add: + +#[ORM\OneToOne(targetEntity: PuzzleStatistics::class, mappedBy: 'puzzle', cascade: ['persist', 'remove'])] +public ?PuzzleStatistics $statistics = null; +``` + +--- + +## Step 3: Statistics Calculator Service + +```php +namespace App\Service; + +use App\DTO\PuzzleStatisticsData; +use Doctrine\DBAL\Connection; + +class PuzzleStatisticsCalculator +{ + public function __construct( + private Connection $connection, + ) {} + + public function calculateForPuzzle(int $puzzleId): PuzzleStatisticsData + { + $result = $this->connection->executeQuery(" + SELECT + -- Total + COUNT(*) AS total_count, + MIN(seconds_to_solve) AS fastest_time, + AVG(seconds_to_solve)::int AS average_time, + MAX(seconds_to_solve) AS slowest_time, + + -- Solo (using new column!) + COUNT(*) FILTER (WHERE puzzling_type = 'solo') AS solo_count, + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo') AS fastest_time_solo, + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'solo') AS average_time_solo, + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo') AS slowest_time_solo, + + -- Duo + COUNT(*) FILTER (WHERE puzzling_type = 'duo') AS duo_count, + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo') AS fastest_time_duo, + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'duo') AS average_time_duo, + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo') AS slowest_time_duo, + + -- Team + COUNT(*) FILTER (WHERE puzzling_type = 'team') AS team_count, + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'team') AS fastest_time_team, + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'team') AS average_time_team, + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'team') AS slowest_time_team + FROM puzzle_solving_time + WHERE puzzle_id = :puzzleId + ", ['puzzleId' => $puzzleId])->fetchAssociative(); + + if ($result === false || (int) $result['total_count'] === 0) { + return PuzzleStatisticsData::empty(); + } + + return new PuzzleStatisticsData( + totalCount: (int) $result['total_count'], + fastestTime: $result['fastest_time'], + averageTime: $result['average_time'], + slowestTime: $result['slowest_time'], + + soloCount: (int) $result['solo_count'], + fastestTimeSolo: $result['fastest_time_solo'], + averageTimeSolo: $result['average_time_solo'], + slowestTimeSolo: $result['slowest_time_solo'], + + duoCount: (int) $result['duo_count'], + fastestTimeDuo: $result['fastest_time_duo'], + averageTimeDuo: $result['average_time_duo'], + slowestTimeDuo: $result['slowest_time_duo'], + + teamCount: (int) $result['team_count'], + fastestTimeTeam: $result['fastest_time_team'], + averageTimeTeam: $result['average_time_team'], + slowestTimeTeam: $result['slowest_time_team'], + ); + } +} +``` + +--- + +## Step 4: Domain Events + Subscriber + +### 4.1 Events + +```php +namespace App\Event; + +readonly class PuzzleSolvingTimeCreated +{ + public function __construct( + public int $puzzleId, + ) {} +} + +readonly class PuzzleSolvingTimeDeleted +{ + public function __construct( + public int $puzzleId, + ) {} +} + +readonly class PuzzleSolvingTimeUpdated +{ + public function __construct( + public int $puzzleId, + ) {} +} +``` + +### 4.2 Event Subscriber + +```php +namespace App\EventSubscriber; + +use App\Entity\PuzzleStatistics; +use App\Event\PuzzleSolvingTimeCreated; +use App\Event\PuzzleSolvingTimeDeleted; +use App\Event\PuzzleSolvingTimeUpdated; +use App\Repository\PuzzleRepository; +use App\Repository\PuzzleStatisticsRepository; +use App\Service\PuzzleStatisticsCalculator; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +class RecalculatePuzzleStatisticsOnSolvingTimeChange +{ + public function __construct( + private PuzzleRepository $puzzles, + private PuzzleStatisticsRepository $statisticsRepository, + private PuzzleStatisticsCalculator $calculator, + private EntityManagerInterface $em, + ) {} + + #[AsEventListener(PuzzleSolvingTimeCreated::class)] + #[AsEventListener(PuzzleSolvingTimeDeleted::class)] + #[AsEventListener(PuzzleSolvingTimeUpdated::class)] + public function __invoke( + PuzzleSolvingTimeCreated|PuzzleSolvingTimeDeleted|PuzzleSolvingTimeUpdated $event + ): void { + $puzzle = $this->puzzles->find($event->puzzleId); + + if ($puzzle === null) { + return; + } + + $statistics = $this->statisticsRepository->findByPuzzle($puzzle); + + if ($statistics === null) { + $statistics = new PuzzleStatistics($puzzle); + $this->em->persist($statistics); + } + + $data = $this->calculator->calculateForPuzzle($event->puzzleId); + $statistics->update($data); + + $this->em->flush(); + } +} +``` + +--- + +## Step 5: Cron Command (Hourly Full Recalculation) + +```php +namespace App\Command; + +use Doctrine\DBAL\Connection; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +#[AsCommand( + name: 'app:recalculate-puzzle-statistics', + description: 'Recalculates all puzzle statistics', +)] +class RecalculatePuzzleStatisticsCommand extends Command +{ + public function __construct( + private Connection $connection, + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->info('Recalculating puzzle statistics...'); + + // Upsert all statistics using INSERT ... ON CONFLICT + $affected = $this->connection->executeStatement(" + INSERT INTO puzzle_statistics ( + puzzle_id, + solved_times_count, fastest_time, average_time, slowest_time, + solved_times_solo_count, fastest_time_solo, average_time_solo, slowest_time_solo, + solved_times_duo_count, fastest_time_duo, average_time_duo, slowest_time_duo, + solved_times_team_count, fastest_time_team, average_time_team, slowest_time_team + ) + SELECT + puzzle_id, + + COUNT(*), + MIN(seconds_to_solve), + AVG(seconds_to_solve)::int, + MAX(seconds_to_solve), + + COUNT(*) FILTER (WHERE puzzling_type = 'solo'), + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo'), + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'solo'), + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo'), + + COUNT(*) FILTER (WHERE puzzling_type = 'duo'), + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo'), + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'duo'), + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo'), + + COUNT(*) FILTER (WHERE puzzling_type = 'team'), + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'team'), + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'team'), + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'team') + FROM puzzle_solving_time + GROUP BY puzzle_id + ON CONFLICT (puzzle_id) DO UPDATE SET + solved_times_count = EXCLUDED.solved_times_count, + fastest_time = EXCLUDED.fastest_time, + average_time = EXCLUDED.average_time, + slowest_time = EXCLUDED.slowest_time, + + solved_times_solo_count = EXCLUDED.solved_times_solo_count, + fastest_time_solo = EXCLUDED.fastest_time_solo, + average_time_solo = EXCLUDED.average_time_solo, + slowest_time_solo = EXCLUDED.slowest_time_solo, + + solved_times_duo_count = EXCLUDED.solved_times_duo_count, + fastest_time_duo = EXCLUDED.fastest_time_duo, + average_time_duo = EXCLUDED.average_time_duo, + slowest_time_duo = EXCLUDED.slowest_time_duo, + + solved_times_team_count = EXCLUDED.solved_times_team_count, + fastest_time_team = EXCLUDED.fastest_time_team, + average_time_team = EXCLUDED.average_time_team, + slowest_time_team = EXCLUDED.slowest_time_team + "); + + // Reset statistics for puzzles with no solving times + $this->connection->executeStatement(" + UPDATE puzzle_statistics ps + SET + solved_times_count = 0, + fastest_time = NULL, + average_time = NULL, + slowest_time = NULL, + solved_times_solo_count = 0, + fastest_time_solo = NULL, + average_time_solo = NULL, + slowest_time_solo = NULL, + solved_times_duo_count = 0, + fastest_time_duo = NULL, + average_time_duo = NULL, + slowest_time_duo = NULL, + solved_times_team_count = 0, + fastest_time_team = NULL, + average_time_team = NULL, + slowest_time_team = NULL + WHERE NOT EXISTS ( + SELECT 1 FROM puzzle_solving_time pst WHERE pst.puzzle_id = ps.puzzle_id + ) + AND ps.solved_times_count > 0 + "); + + $io->success("Processed $affected puzzle statistics"); + + return Command::SUCCESS; + } +} +``` + +### Crontab Entry + +```cron +# Recalculate puzzle statistics every hour +0 * * * * cd /path/to/project && php bin/console app:recalculate-puzzle-statistics --env=prod >> /var/log/puzzle-stats.log 2>&1 +``` + +--- + +## Step 6: Initial Data Population + +Run once after migrations: + +```sql +-- 1. First populate players_count and puzzling_type on puzzle_solving_time +UPDATE puzzle_solving_time +SET + players_count = CASE + WHEN team IS NULL THEN 1 + ELSE json_array_length(team->'puzzlers') + END, + puzzling_type = CASE + WHEN team IS NULL THEN 'solo' + WHEN json_array_length(team->'puzzlers') = 2 THEN 'duo' + ELSE 'team' + END +WHERE players_count = 1 AND team IS NOT NULL; -- Only update rows that need it + +-- 2. Then run the cron command to populate puzzle_statistics +-- php bin/console app:recalculate-puzzle-statistics +``` + +--- + +## Step 7: Update Search Queries + +### 7.1 Optimized Search Query + +```php +namespace App\Repository; + +use Doctrine\DBAL\Connection; + +class PuzzleSearchRepository +{ + public function __construct( + private Connection $connection, + ) {} + + public function search(PuzzleSearchCriteria $criteria): array + { + $sortClause = $this->getSortClause($criteria->sort); + + return $this->connection->executeQuery(" + SELECT + p.id AS puzzle_id, + p.name AS puzzle_name, + p.image AS puzzle_image, + p.alternative_name AS puzzle_alternative_name, + p.pieces_count, + p.is_available, + p.approved AS puzzle_approved, + p.ean AS puzzle_ean, + p.identification_number AS puzzle_identification_number, + + m.name AS manufacturer_name, + m.id AS manufacturer_id, + + -- Statistics from dedicated table + COALESCE(ps.solved_times_count, 0) AS solved_times_count, + ps.fastest_time, + ps.average_time, + ps.slowest_time, + + COALESCE(ps.solved_times_solo_count, 0) AS solved_times_solo_count, + ps.fastest_time_solo, + ps.average_time_solo, + ps.slowest_time_solo, + + COALESCE(ps.solved_times_duo_count, 0) AS solved_times_duo_count, + ps.fastest_time_duo, + ps.average_time_duo, + ps.slowest_time_duo, + + COALESCE(ps.solved_times_team_count, 0) AS solved_times_team_count, + ps.fastest_time_team, + ps.average_time_team, + ps.slowest_time_team, + + -- Match score + CASE + WHEN p.alternative_name ILIKE :exactSearch OR p.name ILIKE :exactSearch + OR p.identification_number = :exact OR p.ean = :exact THEN 7 + WHEN p.identification_number LIKE :prefixSearch OR p.ean LIKE :prefixSearch THEN 5 + WHEN p.name ILIKE :containsSearch OR p.alternative_name ILIKE :containsSearch THEN 4 + ELSE 0 + END AS match_score + FROM puzzle p + JOIN manufacturer m ON m.id = p.manufacturer_id + LEFT JOIN puzzle_statistics ps ON ps.puzzle_id = p.id + WHERE + (:manufacturer::int IS NULL OR p.manufacturer_id = :manufacturer) + AND (:minPieces::int IS NULL OR p.pieces_count >= :minPieces) + AND (:maxPieces::int IS NULL OR p.pieces_count <= :maxPieces) + AND ( + p.name ILIKE :containsSearch + OR p.alternative_name ILIKE :containsSearch + OR p.identification_number LIKE :prefixSearch + OR p.ean LIKE :prefixSearch + ) + ORDER BY {$sortClause}, match_score DESC, p.name ASC + LIMIT :limit OFFSET :offset + ", [ + 'manufacturer' => $criteria->manufacturerId, + 'minPieces' => $criteria->minPieces, + 'maxPieces' => $criteria->maxPieces, + 'exact' => $criteria->search, + 'exactSearch' => $criteria->search, + 'prefixSearch' => $criteria->search . '%', + 'containsSearch' => '%' . $criteria->search . '%', + 'limit' => $criteria->limit, + 'offset' => $criteria->offset, + ])->fetchAllAssociative(); + } + + private function getSortClause(string $sortOption): string + { + return match ($sortOption) { + 'solved_times_asc' => 'COALESCE(ps.solved_times_count, 0) ASC', + 'solved_times_desc' => 'COALESCE(ps.solved_times_count, 0) DESC', + 'solved_times_solo_asc' => 'COALESCE(ps.solved_times_solo_count, 0) ASC', + 'solved_times_solo_desc' => 'COALESCE(ps.solved_times_solo_count, 0) DESC', + 'solved_times_duo_asc' => 'COALESCE(ps.solved_times_duo_count, 0) ASC', + 'solved_times_duo_desc' => 'COALESCE(ps.solved_times_duo_count, 0) DESC', + 'solved_times_team_asc' => 'COALESCE(ps.solved_times_team_count, 0) ASC', + 'solved_times_team_desc' => 'COALESCE(ps.solved_times_team_count, 0) DESC', + 'fastest_time_asc' => 'ps.fastest_time ASC NULLS LAST', + 'fastest_time_desc' => 'ps.fastest_time DESC NULLS LAST', + 'fastest_time_solo_asc' => 'ps.fastest_time_solo ASC NULLS LAST', + 'fastest_time_solo_desc' => 'ps.fastest_time_solo DESC NULLS LAST', + 'name_asc' => 'p.name ASC', + 'name_desc' => 'p.name DESC', + 'pieces_asc' => 'p.pieces_count ASC', + 'pieces_desc' => 'p.pieces_count DESC', + default => 'COALESCE(ps.solved_times_count, 0) ASC', + }; + } +} +``` + +--- + +## Additional Optimizations + +### Trigram Indexes for ILIKE Searches + +```sql +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX idx_puzzle_name_trgm ON puzzle USING gin (name gin_trgm_ops); +CREATE INDEX idx_puzzle_altname_trgm ON puzzle USING gin (alternative_name gin_trgm_ops); +``` + +### For Accented Search (Application-side normalization) + +Instead of custom Postgres functions, normalize in PHP: + +```php +// In your search service/controller +$searchNormalized = transliterator_transliterate( + 'Any-Latin; Latin-ASCII; Lower()', + $search +); +``` + +### Essential Supporting Indexes + +```sql +-- For puzzle filtering +CREATE INDEX idx_puzzle_manufacturer_pieces ON puzzle (manufacturer_id, pieces_count); +CREATE INDEX idx_puzzle_ean ON puzzle (ean) WHERE ean IS NOT NULL; +CREATE INDEX idx_puzzle_identification ON puzzle (identification_number) WHERE identification_number IS NOT NULL; + +-- For puzzle_solving_time +CREATE INDEX idx_pst_puzzle_id ON puzzle_solving_time (puzzle_id); +``` + +--- + +## Expected Performance Impact + +| Scenario | Before | After | +|----------|--------|-------| +| Sort by solved_times | ~2000ms | **~30-50ms** | +| Sort by fastest_time | ~2000ms | **~30-50ms** | +| Statistics calculation (single puzzle) | N/A | **~5ms** (no JSON parsing) | +| Full recalculation (25k puzzles) | N/A | **~2-5s** | + +--- + +## Architecture Summary + +``` +┌─────────────────────────────────────────────────────────────┐ +│ puzzle_solving_time │ +│ + players_count (smallint) │ +│ + puzzling_type (enum: solo/duo/team) │ +└─────────────────────────────────────────────────────────────┘ + │ + │ Domain Event + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ RecalculatePuzzleStatisticsSubscriber │ +│ │ │ +│ ▼ │ +│ PuzzleStatisticsCalculator │ +│ (uses puzzling_type) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ puzzle_statistics │ +│ (separate table, 1:1 with puzzle) │ +│ - All 16 stats columns │ +│ - Indexed for sorting │ +└─────────────────────────────────────────────────────────────┘ + │ + │ LEFT JOIN + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Search Query │ +│ - No aggregation needed │ +│ - Simple column access │ +│ - Fast sorting on indexed columns │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Migration Checklist + +### Step 1: Infrastructure (Deploy without query changes) +- [ ] Add `players_count` and `puzzling_type` columns to `puzzle_solving_time` +- [ ] Populate existing rows with correct values +- [ ] Add index on `puzzling_type` +- [ ] Update `PuzzleSolvingTime` entity to set new columns on construct +- [ ] Create `PuzzlingType` enum +- [ ] Create `puzzle_statistics` table +- [ ] Add indexes on `puzzle_statistics` sortable columns +- [ ] Create `PuzzleStatistics` entity +- [ ] Create `PuzzleStatisticsData` DTO +- [ ] Create `PuzzleStatisticsCalculator` service +- [ ] Create domain events +- [ ] Create event subscriber +- [ ] Create cron command +- [ ] Run initial statistics population +- [ ] Set up crontab entry +- [ ] **Test:** Add solving time → verify statistics update +- [ ] **Test:** Run cron → verify all statistics updated + +### Step 2: Query Migration +- [ ] Update search repository to use `puzzle_statistics` table +- [ ] Remove old aggregation queries +- [ ] Add trigram indexes +- [ ] Run `ANALYZE puzzle; ANALYZE puzzle_statistics; ANALYZE puzzle_solving_time;` +- [ ] **Benchmark:** Verify ~50x performance improvement diff --git a/migrations/Version20260102185021.php b/migrations/Version20260102185021.php new file mode 100644 index 00000000..2e1fd3f3 --- /dev/null +++ b/migrations/Version20260102185021.php @@ -0,0 +1,43 @@ +addSql('ALTER TABLE puzzle_solving_time ADD puzzlers_count SMALLINT NOT NULL DEFAULT 1'); + $this->addSql('ALTER TABLE puzzle_solving_time ADD puzzling_type VARCHAR(10) NOT NULL DEFAULT \'solo\''); + + // Populate from existing data + $this->addSql(" + UPDATE puzzle_solving_time + SET + puzzlers_count = CASE + WHEN team IS NULL THEN 1 + ELSE json_array_length(team->'puzzlers') + END, + puzzling_type = CASE + WHEN team IS NULL THEN 'solo' + WHEN json_array_length(team->'puzzlers') = 2 THEN 'duo' + ELSE 'team' + END + "); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE puzzle_solving_time DROP puzzlers_count'); + $this->addSql('ALTER TABLE puzzle_solving_time DROP puzzling_type'); + } +} diff --git a/migrations/Version20260102185022.php b/migrations/Version20260102185022.php new file mode 100644 index 00000000..fb2a56ac --- /dev/null +++ b/migrations/Version20260102185022.php @@ -0,0 +1,54 @@ +addSql(' + CREATE TABLE puzzle_statistics ( + puzzle_id UUID NOT NULL, + + solved_times_count INT NOT NULL DEFAULT 0, + fastest_time INT DEFAULT NULL, + average_time INT DEFAULT NULL, + slowest_time INT DEFAULT NULL, + + solved_times_solo_count INT NOT NULL DEFAULT 0, + fastest_time_solo INT DEFAULT NULL, + average_time_solo INT DEFAULT NULL, + slowest_time_solo INT DEFAULT NULL, + + solved_times_duo_count INT NOT NULL DEFAULT 0, + fastest_time_duo INT DEFAULT NULL, + average_time_duo INT DEFAULT NULL, + slowest_time_duo INT DEFAULT NULL, + + solved_times_team_count INT NOT NULL DEFAULT 0, + fastest_time_team INT DEFAULT NULL, + average_time_team INT DEFAULT NULL, + slowest_time_team INT DEFAULT NULL, + + PRIMARY KEY(puzzle_id) + ) + '); + + $this->addSql('ALTER TABLE puzzle_statistics ADD CONSTRAINT FK_puzzle_statistics_puzzle FOREIGN KEY (puzzle_id) REFERENCES puzzle (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE puzzle_statistics'); + } +} diff --git a/src/Attribute/DeleteDomainEvent.php b/src/Attribute/DeleteDomainEvent.php new file mode 100644 index 00000000..aa70cdcc --- /dev/null +++ b/src/Attribute/DeleteDomainEvent.php @@ -0,0 +1,19 @@ +info('Recalculating puzzle statistics...'); + + // Upsert all statistics using INSERT ... ON CONFLICT + $affected = $this->connection->executeStatement(" + INSERT INTO puzzle_statistics ( + puzzle_id, + solved_times_count, fastest_time, average_time, slowest_time, + solved_times_solo_count, fastest_time_solo, average_time_solo, slowest_time_solo, + solved_times_duo_count, fastest_time_duo, average_time_duo, slowest_time_duo, + solved_times_team_count, fastest_time_team, average_time_team, slowest_time_team + ) + SELECT + puzzle_id, + + COUNT(*), + MIN(seconds_to_solve), + AVG(seconds_to_solve)::int, + MAX(seconds_to_solve), + + COUNT(*) FILTER (WHERE puzzling_type = 'solo'), + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo'), + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'solo'), + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo'), + + COUNT(*) FILTER (WHERE puzzling_type = 'duo'), + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo'), + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'duo'), + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo'), + + COUNT(*) FILTER (WHERE puzzling_type = 'team'), + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'team'), + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'team'), + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'team') + FROM puzzle_solving_time + GROUP BY puzzle_id + ON CONFLICT (puzzle_id) DO UPDATE SET + solved_times_count = EXCLUDED.solved_times_count, + fastest_time = EXCLUDED.fastest_time, + average_time = EXCLUDED.average_time, + slowest_time = EXCLUDED.slowest_time, + + solved_times_solo_count = EXCLUDED.solved_times_solo_count, + fastest_time_solo = EXCLUDED.fastest_time_solo, + average_time_solo = EXCLUDED.average_time_solo, + slowest_time_solo = EXCLUDED.slowest_time_solo, + + solved_times_duo_count = EXCLUDED.solved_times_duo_count, + fastest_time_duo = EXCLUDED.fastest_time_duo, + average_time_duo = EXCLUDED.average_time_duo, + slowest_time_duo = EXCLUDED.slowest_time_duo, + + solved_times_team_count = EXCLUDED.solved_times_team_count, + fastest_time_team = EXCLUDED.fastest_time_team, + average_time_team = EXCLUDED.average_time_team, + slowest_time_team = EXCLUDED.slowest_time_team + "); + + // Reset statistics for puzzles with no solving times + $this->connection->executeStatement(" + UPDATE puzzle_statistics ps + SET + solved_times_count = 0, + fastest_time = NULL, + average_time = NULL, + slowest_time = NULL, + solved_times_solo_count = 0, + fastest_time_solo = NULL, + average_time_solo = NULL, + slowest_time_solo = NULL, + solved_times_duo_count = 0, + fastest_time_duo = NULL, + average_time_duo = NULL, + slowest_time_duo = NULL, + solved_times_team_count = 0, + fastest_time_team = NULL, + average_time_team = NULL, + slowest_time_team = NULL + WHERE NOT EXISTS ( + SELECT 1 FROM puzzle_solving_time pst WHERE pst.puzzle_id = ps.puzzle_id + ) + AND ps.solved_times_count > 0 + "); + + $io->success("Processed $affected puzzle statistics"); + + return self::SUCCESS; + } +} diff --git a/src/Entity/PuzzleSolvingTime.php b/src/Entity/PuzzleSolvingTime.php index 796dcd57..3175197d 100644 --- a/src/Entity/PuzzleSolvingTime.php +++ b/src/Entity/PuzzleSolvingTime.php @@ -15,16 +15,29 @@ use JetBrains\PhpStorm\Immutable; use Ramsey\Uuid\Doctrine\UuidType; use Ramsey\Uuid\UuidInterface; +use SpeedPuzzling\Web\Attribute\DeleteDomainEvent; use SpeedPuzzling\Web\Doctrine\PuzzlersGroupDoctrineType; use SpeedPuzzling\Web\Events\PuzzleSolved; +use SpeedPuzzling\Web\Events\PuzzleSolvingTimeDeleted; +use SpeedPuzzling\Web\Events\PuzzleSolvingTimeModified; use SpeedPuzzling\Web\Value\PuzzlersGroup; +use SpeedPuzzling\Web\Value\PuzzlingType; #[Entity] -#[Index(columns: ["tracked_at"])] +#[Index(columns: ['tracked_at'])] +#[Index(columns: ['puzzlers_count'])] +#[Index(columns: ['puzzling_type'])] +#[DeleteDomainEvent(PuzzleSolvingTimeDeleted::class)] class PuzzleSolvingTime implements EntityWithEvents { use HasEvents; + #[Column(type: Types::SMALLINT, options: ['default' => 1])] + public int $puzzlersCount; + + #[Column(type: Types::STRING, length: 10, enumType: PuzzlingType::class, options: ['default' => 'solo'])] + public PuzzlingType $puzzlingType; + public function __construct( #[Id] #[Immutable] @@ -63,8 +76,11 @@ public function __construct( #[Column(options: ['default' => false])] public bool $suspicious = false, ) { + $this->puzzlersCount = $this->calculatePuzzlersCount(); + $this->puzzlingType = PuzzlingType::fromPuzzlersCount($this->puzzlersCount); + $this->recordThat( - new PuzzleSolved($this->id), + new PuzzleSolved($this->id, $this->puzzle->id), ); } @@ -84,5 +100,21 @@ public function modify( $this->finishedPuzzlePhoto = $finishedPuzzlePhoto; $this->firstAttempt = $firstAttempt; $this->competition = $competition; + + $this->puzzlersCount = $this->calculatePuzzlersCount(); + $this->puzzlingType = PuzzlingType::fromPuzzlersCount($this->puzzlersCount); + + $this->recordThat( + new PuzzleSolvingTimeModified($this->id, $this->puzzle->id), + ); + } + + private function calculatePuzzlersCount(): int + { + if ($this->team === null) { + return 1; + } + + return count($this->team->puzzlers); } } diff --git a/src/Entity/PuzzleStatistics.php b/src/Entity/PuzzleStatistics.php new file mode 100644 index 00000000..334cb264 --- /dev/null +++ b/src/Entity/PuzzleStatistics.php @@ -0,0 +1,104 @@ + 0])] + public int $solvedTimesCount = 0; + + #[Column(nullable: true)] + public null|int $fastestTime = null; + + #[Column(nullable: true)] + public null|int $averageTime = null; + + #[Column(nullable: true)] + public null|int $slowestTime = null; + + // Solo + #[Column(options: ['default' => 0])] + public int $solvedTimesSoloCount = 0; + + #[Column(nullable: true)] + public null|int $fastestTimeSolo = null; + + #[Column(nullable: true)] + public null|int $averageTimeSolo = null; + + #[Column(nullable: true)] + public null|int $slowestTimeSolo = null; + + // Duo + #[Column(options: ['default' => 0])] + public int $solvedTimesDuoCount = 0; + + #[Column(nullable: true)] + public null|int $fastestTimeDuo = null; + + #[Column(nullable: true)] + public null|int $averageTimeDuo = null; + + #[Column(nullable: true)] + public null|int $slowestTimeDuo = null; + + // Team + #[Column(options: ['default' => 0])] + public int $solvedTimesTeamCount = 0; + + #[Column(nullable: true)] + public null|int $fastestTimeTeam = null; + + #[Column(nullable: true)] + public null|int $averageTimeTeam = null; + + #[Column(nullable: true)] + public null|int $slowestTimeTeam = null; + + public function __construct( + #[Id] + #[Immutable] + #[OneToOne(targetEntity: Puzzle::class)] + #[JoinColumn(name: 'puzzle_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + public Puzzle $puzzle, + ) { + } + + public function update(PuzzleStatisticsData $data): void + { + $this->solvedTimesCount = $data->totalCount; + $this->fastestTime = $data->fastestTime; + $this->averageTime = $data->averageTime; + $this->slowestTime = $data->slowestTime; + + $this->solvedTimesSoloCount = $data->soloCount; + $this->fastestTimeSolo = $data->fastestTimeSolo; + $this->averageTimeSolo = $data->averageTimeSolo; + $this->slowestTimeSolo = $data->slowestTimeSolo; + + $this->solvedTimesDuoCount = $data->duoCount; + $this->fastestTimeDuo = $data->fastestTimeDuo; + $this->averageTimeDuo = $data->averageTimeDuo; + $this->slowestTimeDuo = $data->slowestTimeDuo; + + $this->solvedTimesTeamCount = $data->teamCount; + $this->fastestTimeTeam = $data->fastestTimeTeam; + $this->averageTimeTeam = $data->averageTimeTeam; + $this->slowestTimeTeam = $data->slowestTimeTeam; + } +} diff --git a/src/Events/PuzzleSolved.php b/src/Events/PuzzleSolved.php index d64a75f3..4bd38694 100644 --- a/src/Events/PuzzleSolved.php +++ b/src/Events/PuzzleSolved.php @@ -10,6 +10,7 @@ { public function __construct( public UuidInterface $puzzleSolvingTimeId, + public UuidInterface $puzzleId, ) { } } diff --git a/src/Events/PuzzleSolvingTimeDeleted.php b/src/Events/PuzzleSolvingTimeDeleted.php new file mode 100644 index 00000000..693cd93c --- /dev/null +++ b/src/Events/PuzzleSolvingTimeDeleted.php @@ -0,0 +1,21 @@ +puzzle->id); + } +} diff --git a/src/Events/PuzzleSolvingTimeModified.php b/src/Events/PuzzleSolvingTimeModified.php new file mode 100644 index 00000000..4d6b16a0 --- /dev/null +++ b/src/Events/PuzzleSolvingTimeModified.php @@ -0,0 +1,16 @@ +recalculateForPuzzle($event->puzzleId); + } + + private function recalculateForPuzzle(UuidInterface $puzzleId): void + { + $statistics = $this->statisticsRepository->findByPuzzleId($puzzleId); + + if ($statistics === null) { + $puzzle = $this->puzzleRepository->get($puzzleId->toString()); + $statistics = new PuzzleStatistics($puzzle); + $this->entityManager->persist($statistics); + } + + $data = $this->calculator->calculateForPuzzle($puzzleId); + $statistics->update($data); + } +} diff --git a/src/Repository/PuzzleStatisticsRepository.php b/src/Repository/PuzzleStatisticsRepository.php new file mode 100644 index 00000000..dace6f40 --- /dev/null +++ b/src/Repository/PuzzleStatisticsRepository.php @@ -0,0 +1,27 @@ +entityManager->find(PuzzleStatistics::class, $puzzleId->toString()); + } + + public function save(PuzzleStatistics $statistics): void + { + $this->entityManager->persist($statistics); + } +} diff --git a/src/Services/DomainEventsSubscriber.php b/src/Services/DomainEventsSubscriber.php index 7d62d4bb..8b5349b4 100644 --- a/src/Services/DomainEventsSubscriber.php +++ b/src/Services/DomainEventsSubscriber.php @@ -10,6 +10,8 @@ use Doctrine\ORM\Event\PostRemoveEventArgs; use Doctrine\ORM\Event\PostUpdateEventArgs; use Doctrine\ORM\Events; +use ReflectionClass; +use SpeedPuzzling\Web\Attribute\DeleteDomainEvent; use SpeedPuzzling\Web\Entity\EntityWithEvents; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Service\ResetInterface; @@ -23,6 +25,9 @@ final class DomainEventsSubscriber implements ResetInterface /** @var array */ private array $entities = []; + /** @var array */ + private array $deleteEvents = []; + public function __construct( readonly private MessageBusInterface $messageBus, ) { @@ -31,6 +36,7 @@ public function __construct( public function reset(): void { $this->entities = []; + $this->deleteEvents = []; } public function postPersist(PostPersistEventArgs $eventArgs): void @@ -46,6 +52,7 @@ public function postUpdate(PostUpdateEventArgs $eventArgs): void public function postRemove(PostRemoveEventArgs $eventArgs): void { $this->collectEventsFromEntity($eventArgs); + $this->collectDeleteEvents($eventArgs); } public function postFlush(PostFlushEventArgs $eventArgs): void @@ -63,15 +70,40 @@ private function collectEventsFromEntity( } } + private function collectDeleteEvents(PostRemoveEventArgs $eventArgs): void + { + $entity = $eventArgs->getObject(); + $reflection = new ReflectionClass($entity); + $attributes = $reflection->getAttributes(DeleteDomainEvent::class); + + if (count($attributes) === 0) { + return; + } + + $deleteEventAttribute = $attributes[0]->newInstance(); + $eventClass = $deleteEventAttribute->eventClass; + + /** @var object $event */ + $event = $eventClass::fromEntity($entity); + $this->deleteEvents[] = $event; + } + private function dispatchEvents(): void { $entities = $this->entities; $this->entities = []; + $deleteEvents = $this->deleteEvents; + $this->deleteEvents = []; + foreach ($entities as $entity) { foreach ($entity->popEvents() as $event) { $this->messageBus->dispatch($event); } } + + foreach ($deleteEvents as $event) { + $this->messageBus->dispatch($event); + } } } diff --git a/src/Services/PuzzleStatisticsCalculator.php b/src/Services/PuzzleStatisticsCalculator.php new file mode 100644 index 00000000..e62f4b60 --- /dev/null +++ b/src/Services/PuzzleStatisticsCalculator.php @@ -0,0 +1,83 @@ +connection->executeQuery(" + SELECT + -- Total + COUNT(*) AS total_count, + MIN(seconds_to_solve) AS fastest_time, + AVG(seconds_to_solve)::int AS average_time, + MAX(seconds_to_solve) AS slowest_time, + + -- Solo + COUNT(*) FILTER (WHERE puzzling_type = 'solo') AS solo_count, + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo') AS fastest_time_solo, + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'solo') AS average_time_solo, + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo') AS slowest_time_solo, + + -- Duo + COUNT(*) FILTER (WHERE puzzling_type = 'duo') AS duo_count, + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo') AS fastest_time_duo, + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'duo') AS average_time_duo, + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo') AS slowest_time_duo, + + -- Team + COUNT(*) FILTER (WHERE puzzling_type = 'team') AS team_count, + MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'team') AS fastest_time_team, + AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'team') AS average_time_team, + MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'team') AS slowest_time_team + FROM puzzle_solving_time + WHERE puzzle_id = :puzzleId + ", ['puzzleId' => $puzzleId->toString()])->fetchAssociative(); + + /** @var array{total_count: int|string, fastest_time: int|string|null, average_time: int|string|null, slowest_time: int|string|null, solo_count: int|string, fastest_time_solo: int|string|null, average_time_solo: int|string|null, slowest_time_solo: int|string|null, duo_count: int|string, fastest_time_duo: int|string|null, average_time_duo: int|string|null, slowest_time_duo: int|string|null, team_count: int|string, fastest_time_team: int|string|null, average_time_team: int|string|null, slowest_time_team: int|string|null}|false $result */ + + if ($result === false || (int) $result['total_count'] === 0) { + return PuzzleStatisticsData::empty(); + } + + return new PuzzleStatisticsData( + totalCount: (int) $result['total_count'], + fastestTime: $this->toNullableInt($result['fastest_time']), + averageTime: $this->toNullableInt($result['average_time']), + slowestTime: $this->toNullableInt($result['slowest_time']), + soloCount: (int) $result['solo_count'], + fastestTimeSolo: $this->toNullableInt($result['fastest_time_solo']), + averageTimeSolo: $this->toNullableInt($result['average_time_solo']), + slowestTimeSolo: $this->toNullableInt($result['slowest_time_solo']), + duoCount: (int) $result['duo_count'], + fastestTimeDuo: $this->toNullableInt($result['fastest_time_duo']), + averageTimeDuo: $this->toNullableInt($result['average_time_duo']), + slowestTimeDuo: $this->toNullableInt($result['slowest_time_duo']), + teamCount: (int) $result['team_count'], + fastestTimeTeam: $this->toNullableInt($result['fastest_time_team']), + averageTimeTeam: $this->toNullableInt($result['average_time_team']), + slowestTimeTeam: $this->toNullableInt($result['slowest_time_team']), + ); + } + + private function toNullableInt(null|int|string $value): null|int + { + if ($value === null) { + return null; + } + + return (int) $value; + } +} diff --git a/src/Value/PuzzleStatisticsData.php b/src/Value/PuzzleStatisticsData.php new file mode 100644 index 00000000..a76bde26 --- /dev/null +++ b/src/Value/PuzzleStatisticsData.php @@ -0,0 +1,33 @@ + self::Solo, + $count === 2 => self::Duo, + default => self::Team, + }; + } +} From 3eda765b2560dafa1cb518565d94ff35e1e61885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Fri, 2 Jan 2026 19:25:20 +0100 Subject: [PATCH 2/4] Code updates --- migrations/Version20260102182105.php | 58 +++++++++++++++++++ migrations/Version20260102185021.php | 43 -------------- migrations/Version20260102185022.php | 54 ----------------- src/Attribute/DeleteDomainEvent.php | 3 +- src/Entity/PuzzleSolvingTime.php | 2 +- src/Entity/PuzzleStatistics.php | 23 ++++++-- src/Events/DeleteDomainEventInterface.php | 13 +++++ src/Events/PuzzleSolvingTimeDeleted.php | 8 ++- ...atePuzzleStatisticsOnSolvingTimeChange.php | 5 +- src/Services/DomainEventsSubscriber.php | 9 +-- 10 files changed, 105 insertions(+), 113 deletions(-) create mode 100644 migrations/Version20260102182105.php delete mode 100644 migrations/Version20260102185021.php delete mode 100644 migrations/Version20260102185022.php create mode 100644 src/Events/DeleteDomainEventInterface.php diff --git a/migrations/Version20260102182105.php b/migrations/Version20260102182105.php new file mode 100644 index 00000000..91ed6fd4 --- /dev/null +++ b/migrations/Version20260102182105.php @@ -0,0 +1,58 @@ +addSql('CREATE TABLE puzzle_statistics (solved_times_count INT DEFAULT 0 NOT NULL, fastest_time INT DEFAULT NULL, average_time INT DEFAULT NULL, slowest_time INT DEFAULT NULL, solved_times_solo_count INT DEFAULT 0 NOT NULL, fastest_time_solo INT DEFAULT NULL, average_time_solo INT DEFAULT NULL, slowest_time_solo INT DEFAULT NULL, solved_times_duo_count INT DEFAULT 0 NOT NULL, fastest_time_duo INT DEFAULT NULL, average_time_duo INT DEFAULT NULL, slowest_time_duo INT DEFAULT NULL, solved_times_team_count INT DEFAULT 0 NOT NULL, fastest_time_team INT DEFAULT NULL, average_time_team INT DEFAULT NULL, slowest_time_team INT DEFAULT NULL, puzzle_id UUID NOT NULL, PRIMARY KEY (puzzle_id))'); + $this->addSql('CREATE INDEX IDX_9FC82DAEF6012350 ON puzzle_statistics (solved_times_count)'); + $this->addSql('CREATE INDEX IDX_9FC82DAEBC7ADEC0 ON puzzle_statistics (fastest_time)'); + $this->addSql('ALTER TABLE puzzle_statistics ADD CONSTRAINT FK_9FC82DAED9816812 FOREIGN KEY (puzzle_id) REFERENCES puzzle (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE puzzle_solving_time ADD puzzlers_count SMALLINT DEFAULT 1 NOT NULL'); + $this->addSql('ALTER TABLE puzzle_solving_time ADD puzzling_type VARCHAR(255) DEFAULT \'solo\' NOT NULL'); + $this->addSql('CREATE INDEX IDX_FE83A93C1E0613E4 ON puzzle_solving_time (puzzlers_count)'); + $this->addSql('CREATE INDEX IDX_FE83A93C58DDC291 ON puzzle_solving_time (puzzling_type)'); + + // Populate from existing data + $this->addSql(" + UPDATE puzzle_solving_time + SET + puzzlers_count = CASE + WHEN team IS NULL THEN 1 + ELSE json_array_length(team->'puzzlers') + END, + puzzling_type = CASE + WHEN team IS NULL THEN 'solo' + WHEN json_array_length(team->'puzzlers') = 2 THEN 'duo' + ELSE 'team' + END + "); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE puzzle_statistics DROP CONSTRAINT FK_9FC82DAED9816812'); + $this->addSql('DROP TABLE puzzle_statistics'); + $this->addSql('DROP INDEX IDX_FE83A93C1E0613E4'); + $this->addSql('DROP INDEX IDX_FE83A93C58DDC291'); + $this->addSql('ALTER TABLE puzzle_solving_time DROP puzzlers_count'); + $this->addSql('ALTER TABLE puzzle_solving_time DROP puzzling_type'); + } +} diff --git a/migrations/Version20260102185021.php b/migrations/Version20260102185021.php deleted file mode 100644 index 2e1fd3f3..00000000 --- a/migrations/Version20260102185021.php +++ /dev/null @@ -1,43 +0,0 @@ -addSql('ALTER TABLE puzzle_solving_time ADD puzzlers_count SMALLINT NOT NULL DEFAULT 1'); - $this->addSql('ALTER TABLE puzzle_solving_time ADD puzzling_type VARCHAR(10) NOT NULL DEFAULT \'solo\''); - - // Populate from existing data - $this->addSql(" - UPDATE puzzle_solving_time - SET - puzzlers_count = CASE - WHEN team IS NULL THEN 1 - ELSE json_array_length(team->'puzzlers') - END, - puzzling_type = CASE - WHEN team IS NULL THEN 'solo' - WHEN json_array_length(team->'puzzlers') = 2 THEN 'duo' - ELSE 'team' - END - "); - } - - public function down(Schema $schema): void - { - $this->addSql('ALTER TABLE puzzle_solving_time DROP puzzlers_count'); - $this->addSql('ALTER TABLE puzzle_solving_time DROP puzzling_type'); - } -} diff --git a/migrations/Version20260102185022.php b/migrations/Version20260102185022.php deleted file mode 100644 index fb2a56ac..00000000 --- a/migrations/Version20260102185022.php +++ /dev/null @@ -1,54 +0,0 @@ -addSql(' - CREATE TABLE puzzle_statistics ( - puzzle_id UUID NOT NULL, - - solved_times_count INT NOT NULL DEFAULT 0, - fastest_time INT DEFAULT NULL, - average_time INT DEFAULT NULL, - slowest_time INT DEFAULT NULL, - - solved_times_solo_count INT NOT NULL DEFAULT 0, - fastest_time_solo INT DEFAULT NULL, - average_time_solo INT DEFAULT NULL, - slowest_time_solo INT DEFAULT NULL, - - solved_times_duo_count INT NOT NULL DEFAULT 0, - fastest_time_duo INT DEFAULT NULL, - average_time_duo INT DEFAULT NULL, - slowest_time_duo INT DEFAULT NULL, - - solved_times_team_count INT NOT NULL DEFAULT 0, - fastest_time_team INT DEFAULT NULL, - average_time_team INT DEFAULT NULL, - slowest_time_team INT DEFAULT NULL, - - PRIMARY KEY(puzzle_id) - ) - '); - - $this->addSql('ALTER TABLE puzzle_statistics ADD CONSTRAINT FK_puzzle_statistics_puzzle FOREIGN KEY (puzzle_id) REFERENCES puzzle (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); - } - - public function down(Schema $schema): void - { - $this->addSql('DROP TABLE puzzle_statistics'); - } -} diff --git a/src/Attribute/DeleteDomainEvent.php b/src/Attribute/DeleteDomainEvent.php index aa70cdcc..f33db8ed 100644 --- a/src/Attribute/DeleteDomainEvent.php +++ b/src/Attribute/DeleteDomainEvent.php @@ -5,12 +5,13 @@ namespace SpeedPuzzling\Web\Attribute; use Attribute; +use SpeedPuzzling\Web\Events\DeleteDomainEventInterface; #[Attribute(Attribute::TARGET_CLASS)] final class DeleteDomainEvent { /** - * @param class-string $eventClass + * @param class-string $eventClass */ public function __construct( public string $eventClass, diff --git a/src/Entity/PuzzleSolvingTime.php b/src/Entity/PuzzleSolvingTime.php index 3175197d..cfbf39eb 100644 --- a/src/Entity/PuzzleSolvingTime.php +++ b/src/Entity/PuzzleSolvingTime.php @@ -35,7 +35,7 @@ class PuzzleSolvingTime implements EntityWithEvents #[Column(type: Types::SMALLINT, options: ['default' => 1])] public int $puzzlersCount; - #[Column(type: Types::STRING, length: 10, enumType: PuzzlingType::class, options: ['default' => 'solo'])] + #[Column(options: ['default' => PuzzlingType::Solo->value])] public PuzzlingType $puzzlingType; public function __construct( diff --git a/src/Entity/PuzzleStatistics.php b/src/Entity/PuzzleStatistics.php index 334cb264..008c4ba0 100644 --- a/src/Entity/PuzzleStatistics.php +++ b/src/Entity/PuzzleStatistics.php @@ -19,62 +19,75 @@ class PuzzleStatistics { // Total + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(options: ['default' => 0])] public int $solvedTimesCount = 0; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $fastestTime = null; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $averageTime = null; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $slowestTime = null; - // Solo + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(options: ['default' => 0])] public int $solvedTimesSoloCount = 0; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $fastestTimeSolo = null; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $averageTimeSolo = null; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $slowestTimeSolo = null; - // Duo + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(options: ['default' => 0])] public int $solvedTimesDuoCount = 0; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $fastestTimeDuo = null; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $averageTimeDuo = null; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $slowestTimeDuo = null; - // Team + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(options: ['default' => 0])] public int $solvedTimesTeamCount = 0; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $fastestTimeTeam = null; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $averageTimeTeam = null; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] #[Column(nullable: true)] public null|int $slowestTimeTeam = null; public function __construct( #[Id] #[Immutable] - #[OneToOne(targetEntity: Puzzle::class)] - #[JoinColumn(name: 'puzzle_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[OneToOne] + #[JoinColumn(onDelete: 'CASCADE')] public Puzzle $puzzle, ) { } diff --git a/src/Events/DeleteDomainEventInterface.php b/src/Events/DeleteDomainEventInterface.php new file mode 100644 index 00000000..8e48a28f --- /dev/null +++ b/src/Events/DeleteDomainEventInterface.php @@ -0,0 +1,13 @@ +puzzle->id); } } diff --git a/src/MessageHandler/RecalculatePuzzleStatisticsOnSolvingTimeChange.php b/src/MessageHandler/RecalculatePuzzleStatisticsOnSolvingTimeChange.php index 51dccc9e..759b70eb 100644 --- a/src/MessageHandler/RecalculatePuzzleStatisticsOnSolvingTimeChange.php +++ b/src/MessageHandler/RecalculatePuzzleStatisticsOnSolvingTimeChange.php @@ -4,7 +4,6 @@ namespace SpeedPuzzling\Web\MessageHandler; -use Doctrine\ORM\EntityManagerInterface; use Ramsey\Uuid\UuidInterface; use SpeedPuzzling\Web\Entity\PuzzleStatistics; use SpeedPuzzling\Web\Events\PuzzleSolved; @@ -22,7 +21,6 @@ public function __construct( private PuzzleRepository $puzzleRepository, private PuzzleStatisticsRepository $statisticsRepository, private PuzzleStatisticsCalculator $calculator, - private EntityManagerInterface $entityManager, ) { } @@ -37,8 +35,9 @@ private function recalculateForPuzzle(UuidInterface $puzzleId): void if ($statistics === null) { $puzzle = $this->puzzleRepository->get($puzzleId->toString()); + $statistics = new PuzzleStatistics($puzzle); - $this->entityManager->persist($statistics); + $this->statisticsRepository->save($statistics); } $data = $this->calculator->calculateForPuzzle($puzzleId); diff --git a/src/Services/DomainEventsSubscriber.php b/src/Services/DomainEventsSubscriber.php index 8b5349b4..f3a05240 100644 --- a/src/Services/DomainEventsSubscriber.php +++ b/src/Services/DomainEventsSubscriber.php @@ -13,6 +13,7 @@ use ReflectionClass; use SpeedPuzzling\Web\Attribute\DeleteDomainEvent; use SpeedPuzzling\Web\Entity\EntityWithEvents; +use SpeedPuzzling\Web\Events\DeleteDomainEventInterface; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Service\ResetInterface; @@ -25,7 +26,7 @@ final class DomainEventsSubscriber implements ResetInterface /** @var array */ private array $entities = []; - /** @var array */ + /** @var array */ private array $deleteEvents = []; public function __construct( @@ -81,11 +82,11 @@ private function collectDeleteEvents(PostRemoveEventArgs $eventArgs): void } $deleteEventAttribute = $attributes[0]->newInstance(); + + /** @var class-string $eventClass */ $eventClass = $deleteEventAttribute->eventClass; - /** @var object $event */ - $event = $eventClass::fromEntity($entity); - $this->deleteEvents[] = $event; + $this->deleteEvents[] = $eventClass::fromEntity($entity); } private function dispatchEvents(): void From 88e713aa8244b566f0f3b9baf78396c987995bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Fri, 2 Jan 2026 19:28:18 +0100 Subject: [PATCH 3/4] Renaming --- ...{DeleteDomainEvent.php => HasDeleteDomainEvent.php} | 6 +++--- src/Entity/PuzzleSolvingTime.php | 4 ++-- ...eDomainEventInterface.php => DeleteDomainEvent.php} | 2 +- src/Events/PuzzleSolvingTimeDeleted.php | 2 +- src/Services/DomainEventsSubscriber.php | 10 +++++----- 5 files changed, 12 insertions(+), 12 deletions(-) rename src/Attribute/{DeleteDomainEvent.php => HasDeleteDomainEvent.php} (59%) rename src/Events/{DeleteDomainEventInterface.php => DeleteDomainEvent.php} (85%) diff --git a/src/Attribute/DeleteDomainEvent.php b/src/Attribute/HasDeleteDomainEvent.php similarity index 59% rename from src/Attribute/DeleteDomainEvent.php rename to src/Attribute/HasDeleteDomainEvent.php index f33db8ed..f84d1148 100644 --- a/src/Attribute/DeleteDomainEvent.php +++ b/src/Attribute/HasDeleteDomainEvent.php @@ -5,13 +5,13 @@ namespace SpeedPuzzling\Web\Attribute; use Attribute; -use SpeedPuzzling\Web\Events\DeleteDomainEventInterface; +use SpeedPuzzling\Web\Events\DeleteDomainEvent; #[Attribute(Attribute::TARGET_CLASS)] -final class DeleteDomainEvent +final class HasDeleteDomainEvent { /** - * @param class-string $eventClass + * @param class-string $eventClass */ public function __construct( public string $eventClass, diff --git a/src/Entity/PuzzleSolvingTime.php b/src/Entity/PuzzleSolvingTime.php index cfbf39eb..7ed80cd5 100644 --- a/src/Entity/PuzzleSolvingTime.php +++ b/src/Entity/PuzzleSolvingTime.php @@ -15,7 +15,7 @@ use JetBrains\PhpStorm\Immutable; use Ramsey\Uuid\Doctrine\UuidType; use Ramsey\Uuid\UuidInterface; -use SpeedPuzzling\Web\Attribute\DeleteDomainEvent; +use SpeedPuzzling\Web\Attribute\HasDeleteDomainEvent; use SpeedPuzzling\Web\Doctrine\PuzzlersGroupDoctrineType; use SpeedPuzzling\Web\Events\PuzzleSolved; use SpeedPuzzling\Web\Events\PuzzleSolvingTimeDeleted; @@ -27,7 +27,7 @@ #[Index(columns: ['tracked_at'])] #[Index(columns: ['puzzlers_count'])] #[Index(columns: ['puzzling_type'])] -#[DeleteDomainEvent(PuzzleSolvingTimeDeleted::class)] +#[HasDeleteDomainEvent(PuzzleSolvingTimeDeleted::class)] class PuzzleSolvingTime implements EntityWithEvents { use HasEvents; diff --git a/src/Events/DeleteDomainEventInterface.php b/src/Events/DeleteDomainEvent.php similarity index 85% rename from src/Events/DeleteDomainEventInterface.php rename to src/Events/DeleteDomainEvent.php index 8e48a28f..cdb6e672 100644 --- a/src/Events/DeleteDomainEventInterface.php +++ b/src/Events/DeleteDomainEvent.php @@ -7,7 +7,7 @@ /** * Interface for delete domain events that can be created from an entity. */ -interface DeleteDomainEventInterface +interface DeleteDomainEvent { public static function fromEntity(object $entity): static; } diff --git a/src/Events/PuzzleSolvingTimeDeleted.php b/src/Events/PuzzleSolvingTimeDeleted.php index 8ae1c690..53f5dd5e 100644 --- a/src/Events/PuzzleSolvingTimeDeleted.php +++ b/src/Events/PuzzleSolvingTimeDeleted.php @@ -7,7 +7,7 @@ use Ramsey\Uuid\UuidInterface; use SpeedPuzzling\Web\Entity\PuzzleSolvingTime; -readonly final class PuzzleSolvingTimeDeleted implements DeleteDomainEventInterface +readonly final class PuzzleSolvingTimeDeleted implements DeleteDomainEvent { public function __construct( public UuidInterface $puzzleId, diff --git a/src/Services/DomainEventsSubscriber.php b/src/Services/DomainEventsSubscriber.php index f3a05240..dbda39a3 100644 --- a/src/Services/DomainEventsSubscriber.php +++ b/src/Services/DomainEventsSubscriber.php @@ -11,9 +11,9 @@ use Doctrine\ORM\Event\PostUpdateEventArgs; use Doctrine\ORM\Events; use ReflectionClass; -use SpeedPuzzling\Web\Attribute\DeleteDomainEvent; +use SpeedPuzzling\Web\Attribute\HasDeleteDomainEvent; use SpeedPuzzling\Web\Entity\EntityWithEvents; -use SpeedPuzzling\Web\Events\DeleteDomainEventInterface; +use SpeedPuzzling\Web\Events\DeleteDomainEvent; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Contracts\Service\ResetInterface; @@ -26,7 +26,7 @@ final class DomainEventsSubscriber implements ResetInterface /** @var array */ private array $entities = []; - /** @var array */ + /** @var array */ private array $deleteEvents = []; public function __construct( @@ -75,7 +75,7 @@ private function collectDeleteEvents(PostRemoveEventArgs $eventArgs): void { $entity = $eventArgs->getObject(); $reflection = new ReflectionClass($entity); - $attributes = $reflection->getAttributes(DeleteDomainEvent::class); + $attributes = $reflection->getAttributes(HasDeleteDomainEvent::class); if (count($attributes) === 0) { return; @@ -83,7 +83,7 @@ private function collectDeleteEvents(PostRemoveEventArgs $eventArgs): void $deleteEventAttribute = $attributes[0]->newInstance(); - /** @var class-string $eventClass */ + /** @var class-string $eventClass */ $eventClass = $deleteEventAttribute->eventClass; $this->deleteEvents[] = $eventClass::fromEntity($entity); From c7dfdc101d306237934e246c83d2bbb21adf937f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Fri, 2 Jan 2026 19:40:53 +0100 Subject: [PATCH 4/4] Calculations --- .../RecalculatePuzzleStatisticsConsoleCommand.php | 6 +++--- src/Services/PuzzleStatisticsCalculator.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ConsoleCommands/RecalculatePuzzleStatisticsConsoleCommand.php b/src/ConsoleCommands/RecalculatePuzzleStatisticsConsoleCommand.php index e5a3e19a..c70c7c15 100644 --- a/src/ConsoleCommands/RecalculatePuzzleStatisticsConsoleCommand.php +++ b/src/ConsoleCommands/RecalculatePuzzleStatisticsConsoleCommand.php @@ -44,17 +44,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int COUNT(*) FILTER (WHERE puzzling_type = 'solo'), MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo'), - AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'solo'), + (AVG(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo'))::int, MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo'), COUNT(*) FILTER (WHERE puzzling_type = 'duo'), MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo'), - AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'duo'), + (AVG(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo'))::int, MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo'), COUNT(*) FILTER (WHERE puzzling_type = 'team'), MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'team'), - AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'team'), + (AVG(seconds_to_solve) FILTER (WHERE puzzling_type = 'team'))::int, MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'team') FROM puzzle_solving_time GROUP BY puzzle_id diff --git a/src/Services/PuzzleStatisticsCalculator.php b/src/Services/PuzzleStatisticsCalculator.php index e62f4b60..c3bd9e0a 100644 --- a/src/Services/PuzzleStatisticsCalculator.php +++ b/src/Services/PuzzleStatisticsCalculator.php @@ -28,19 +28,19 @@ public function calculateForPuzzle(UuidInterface $puzzleId): PuzzleStatisticsDat -- Solo COUNT(*) FILTER (WHERE puzzling_type = 'solo') AS solo_count, MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo') AS fastest_time_solo, - AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'solo') AS average_time_solo, + (AVG(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo'))::int AS average_time_solo, MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'solo') AS slowest_time_solo, -- Duo COUNT(*) FILTER (WHERE puzzling_type = 'duo') AS duo_count, MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo') AS fastest_time_duo, - AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'duo') AS average_time_duo, + (AVG(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo'))::int AS average_time_duo, MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'duo') AS slowest_time_duo, -- Team COUNT(*) FILTER (WHERE puzzling_type = 'team') AS team_count, MIN(seconds_to_solve) FILTER (WHERE puzzling_type = 'team') AS fastest_time_team, - AVG(seconds_to_solve)::int FILTER (WHERE puzzling_type = 'team') AS average_time_team, + (AVG(seconds_to_solve) FILTER (WHERE puzzling_type = 'team'))::int AS average_time_team, MAX(seconds_to_solve) FILTER (WHERE puzzling_type = 'team') AS slowest_time_team FROM puzzle_solving_time WHERE puzzle_id = :puzzleId