Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
856 changes: 856 additions & 0 deletions .claude/postgres-puzzle-query-optimization.md

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions migrations/Version20260102182105.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260102182105 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
20 changes: 20 additions & 0 deletions src/Attribute/HasDeleteDomainEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\Attribute;

use Attribute;
use SpeedPuzzling\Web\Events\DeleteDomainEvent;

#[Attribute(Attribute::TARGET_CLASS)]
final class HasDeleteDomainEvent
{
/**
* @param class-string<DeleteDomainEvent> $eventClass
*/
public function __construct(
public string $eventClass,
) {
}
}
113 changes: 113 additions & 0 deletions src/ConsoleCommands/RecalculatePuzzleStatisticsConsoleCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\ConsoleCommands;

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('myspeedpuzzling:recalculate-puzzle-statistics')]
final class RecalculatePuzzleStatisticsConsoleCommand extends Command
{
public function __construct(
readonly 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) 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) 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) FILTER (WHERE puzzling_type = 'team'))::int,
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;
}
}
36 changes: 34 additions & 2 deletions src/Entity/PuzzleSolvingTime.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,29 @@
use JetBrains\PhpStorm\Immutable;
use Ramsey\Uuid\Doctrine\UuidType;
use Ramsey\Uuid\UuidInterface;
use SpeedPuzzling\Web\Attribute\HasDeleteDomainEvent;
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'])]
#[HasDeleteDomainEvent(PuzzleSolvingTimeDeleted::class)]
class PuzzleSolvingTime implements EntityWithEvents
{
use HasEvents;

#[Column(type: Types::SMALLINT, options: ['default' => 1])]
public int $puzzlersCount;

#[Column(options: ['default' => PuzzlingType::Solo->value])]
public PuzzlingType $puzzlingType;

public function __construct(
#[Id]
#[Immutable]
Expand Down Expand Up @@ -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),
);
}

Expand All @@ -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);
}
}
117 changes: 117 additions & 0 deletions src/Entity/PuzzleStatistics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\Entity;

use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Index;
use Doctrine\ORM\Mapping\JoinColumn;
use Doctrine\ORM\Mapping\OneToOne;
use JetBrains\PhpStorm\Immutable;
use SpeedPuzzling\Web\Value\PuzzleStatisticsData;

#[Entity]
#[Index(columns: ['solved_times_count'])]
#[Index(columns: ['fastest_time'])]
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;

#[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;

#[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;

#[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]
#[JoinColumn(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;
}
}
13 changes: 13 additions & 0 deletions src/Events/DeleteDomainEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace SpeedPuzzling\Web\Events;

/**
* Interface for delete domain events that can be created from an entity.
*/
interface DeleteDomainEvent
{
public static function fromEntity(object $entity): static;
}
1 change: 1 addition & 0 deletions src/Events/PuzzleSolved.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
{
public function __construct(
public UuidInterface $puzzleSolvingTimeId,
public UuidInterface $puzzleId,
) {
}
}
Loading