docs: add implementation plan for awards in database

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thibaud-leclere
2026-04-01 14:20:20 +02:00
parent d2d211a228
commit 76013afb1c

View File

@@ -0,0 +1,881 @@
# Awards en BDD — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Persist actor awards in the database during film import and use them for hint generation instead of live Wikidata calls.
**Architecture:** Two new Doctrine entities (`AwardType`, `Award`) plus a `awardsImported` flag on `Actor`. A new `AwardImporter` service handles Wikidata fetching + pattern-based type resolution during the existing batch import flow. `GameGridGenerator` switches from Wikidata calls to DB queries for award hints.
**Tech Stack:** Symfony 7, Doctrine ORM, PHPUnit 12, PostgreSQL (RANDOM() function used in existing queries)
---
## File Structure
| Action | Path | Responsibility |
|--------|------|---------------|
| Create | `src/Entity/AwardType.php` | Doctrine entity for award types |
| Create | `src/Entity/Award.php` | Doctrine entity for individual awards |
| Create | `src/Repository/AwardTypeRepository.php` | AwardType queries |
| Create | `src/Repository/AwardRepository.php` | Award queries (random by actor) |
| Create | `src/Service/AwardImporter.php` | Orchestrates Wikidata fetch + type resolution + persist |
| Create | `tests/Service/AwardImporterTest.php` | Unit tests for import logic |
| Create | `tests/Service/GameGridGeneratorTest.php` | Unit tests for updated hint generation |
| Modify | `src/Entity/Actor.php` | Add `awardsImported` bool field + `awards` collection |
| Modify | `src/MessageHandler/ImportFilmsBatchMessageHandler.php` | Call `AwardImporter` after actor sync |
| Modify | `src/Service/GameGridGenerator.php` | Replace Wikidata call with DB query |
| Create | `migrations/VersionXXX.php` | Generated by doctrine:migrations:diff |
---
### Task 1: Create `AwardType` entity
**Files:**
- Create: `src/Entity/AwardType.php`
- Create: `src/Repository/AwardTypeRepository.php`
- [ ] **Step 1: Create the entity**
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AwardTypeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AwardTypeRepository::class)]
class AwardType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(length: 255)]
private string $pattern;
/** @var Collection<int, Award> */
#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'awardType')]
private Collection $awards;
public function __construct()
{
$this->awards = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPattern(): string
{
return $this->pattern;
}
public function setPattern(string $pattern): static
{
$this->pattern = $pattern;
return $this;
}
/** @return Collection<int, Award> */
public function getAwards(): Collection
{
return $this->awards;
}
}
```
- [ ] **Step 2: Create the repository**
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AwardType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<AwardType> */
class AwardTypeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AwardType::class);
}
/** @return list<AwardType> */
public function findAll(): array
{
return parent::findAll();
}
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/AwardType.php src/Repository/AwardTypeRepository.php
git commit -m "feat: add AwardType entity and repository"
```
---
### Task 2: Create `Award` entity
**Files:**
- Create: `src/Entity/Award.php`
- Create: `src/Repository/AwardRepository.php`
- [ ] **Step 1: Create the entity**
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AwardRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AwardRepository::class)]
class Award
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: AwardType::class, inversedBy: 'awards')]
#[ORM\JoinColumn(nullable: false)]
private AwardType $awardType;
#[ORM\ManyToOne(targetEntity: Actor::class, inversedBy: 'awards')]
#[ORM\JoinColumn(nullable: false)]
private Actor $actor;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(nullable: true)]
private ?int $year = null;
public function getId(): ?int
{
return $this->id;
}
public function getAwardType(): AwardType
{
return $this->awardType;
}
public function setAwardType(AwardType $awardType): static
{
$this->awardType = $awardType;
return $this;
}
public function getActor(): Actor
{
return $this->actor;
}
public function setActor(Actor $actor): static
{
$this->actor = $actor;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getYear(): ?int
{
return $this->year;
}
public function setYear(?int $year): static
{
$this->year = $year;
return $this;
}
}
```
- [ ] **Step 2: Create the repository with `findOneRandomByActor`**
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Award;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<Award> */
class AwardRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Award::class);
}
public function findOneRandomByActor(int $actorId): ?Award
{
return $this->createQueryBuilder('a')
->andWhere('a.actor = :actorId')
->setParameter('actorId', $actorId)
->orderBy('RANDOM()')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/Award.php src/Repository/AwardRepository.php
git commit -m "feat: add Award entity and repository"
```
---
### Task 3: Add `awardsImported` flag and `awards` collection to `Actor`
**Files:**
- Modify: `src/Entity/Actor.php`
- [ ] **Step 1: Add the `awardsImported` column and `awards` OneToMany relation**
Add these properties after the existing `$tmdbId` property:
```php
#[ORM\Column(options: ['default' => false])]
private bool $awardsImported = false;
/** @var Collection<int, Award> */
#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'actor')]
private Collection $awards;
```
Add `use App\Entity\Award;` import if not already present (it's in the same namespace, so not needed).
In the constructor, add:
```php
$this->awards = new ArrayCollection();
```
Add these methods at the end of the class:
```php
public function isAwardsImported(): bool
{
return $this->awardsImported;
}
public function setAwardsImported(bool $awardsImported): static
{
$this->awardsImported = $awardsImported;
return $this;
}
/** @return Collection<int, Award> */
public function getAwards(): Collection
{
return $this->awards;
}
```
- [ ] **Step 2: Commit**
```bash
git add src/Entity/Actor.php
git commit -m "feat: add awardsImported flag and awards relation to Actor"
```
---
### Task 4: Generate and run the migration
**Files:**
- Create: `migrations/VersionXXX.php` (auto-generated)
- [ ] **Step 1: Generate the migration**
```bash
php bin/console doctrine:migrations:diff
```
Expected: a new migration file is created in `migrations/` with CREATE TABLE for `award_type` and `award`, and ALTER TABLE for `actor` adding `awards_imported`.
- [ ] **Step 2: Review the generated migration**
Open the generated file and verify it contains:
- `CREATE TABLE award_type (id SERIAL, name VARCHAR(255), pattern VARCHAR(255), PRIMARY KEY(id))`
- `CREATE TABLE award (id SERIAL, award_type_id INT NOT NULL, actor_id INT NOT NULL, name VARCHAR(255), year INT DEFAULT NULL, PRIMARY KEY(id))` with foreign keys
- `ALTER TABLE actor ADD awards_imported BOOLEAN NOT NULL DEFAULT false`
- [ ] **Step 3: Run the migration**
```bash
php bin/console doctrine:migrations:migrate --no-interaction
```
Expected: migration executes successfully.
- [ ] **Step 4: Commit**
```bash
git add migrations/
git commit -m "feat: add migration for award_type, award tables and actor.awards_imported"
```
---
### Task 5: Create `AwardImporter` service
**Files:**
- Create: `src/Service/AwardImporter.php`
- [ ] **Step 1: Write the failing test**
Create `tests/Service/AwardImporterTest.php`:
```php
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Gateway\WikidataGateway;
use App\Repository\AwardTypeRepository;
use App\Service\AwardImporter;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
class AwardImporterTest extends TestCase
{
private AwardImporter $importer;
private WikidataGateway&\PHPUnit\Framework\MockObject\MockObject $wikidataGateway;
private AwardTypeRepository&\PHPUnit\Framework\MockObject\MockObject $awardTypeRepository;
private EntityManagerInterface&\PHPUnit\Framework\MockObject\MockObject $em;
protected function setUp(): void
{
$this->wikidataGateway = $this->createMock(WikidataGateway::class);
$this->awardTypeRepository = $this->createMock(AwardTypeRepository::class);
$this->em = $this->createMock(EntityManagerInterface::class);
$this->importer = new AwardImporter(
$this->wikidataGateway,
$this->awardTypeRepository,
$this->em,
);
}
public function testSkipsActorWithAwardsAlreadyImported(): void
{
$actor = $this->createActorWithFlag(awardsImported: true);
$this->wikidataGateway->expects($this->never())->method('getAwards');
$this->importer->importForActor($actor);
}
public function testImportsAwardsAndSetsFlag(): void
{
$actor = $this->createActorWithFlag(awardsImported: false);
$this->wikidataGateway->method('getAwards')->willReturn([
['name' => 'Academy Award for Best Actor', 'year' => 2020],
]);
$existingType = new AwardType();
$existingType->setName('Oscar')->setPattern('Academy Award');
$this->awardTypeRepository->method('findAll')->willReturn([$existingType]);
$persisted = [];
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
$persisted[] = $entity;
});
$this->importer->importForActor($actor);
$this->assertTrue($actor->isAwardsImported());
$this->assertCount(1, $persisted);
$this->assertInstanceOf(Award::class, $persisted[0]);
$this->assertSame('Academy Award for Best Actor', $persisted[0]->getName());
$this->assertSame(2020, $persisted[0]->getYear());
$this->assertSame($existingType, $persisted[0]->getAwardType());
$this->assertSame($actor, $persisted[0]->getActor());
}
public function testCreatesNewAwardTypeWhenNoPatternMatches(): void
{
$actor = $this->createActorWithFlag(awardsImported: false);
$this->wikidataGateway->method('getAwards')->willReturn([
['name' => 'Screen Actors Guild Award for Outstanding Performance', 'year' => 2019],
]);
$this->awardTypeRepository->method('findAll')->willReturn([]);
$persisted = [];
$this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) {
$persisted[] = $entity;
});
$this->importer->importForActor($actor);
$this->assertTrue($actor->isAwardsImported());
// Should persist both a new AwardType and the Award
$this->assertCount(2, $persisted);
$newType = $persisted[0];
$this->assertInstanceOf(AwardType::class, $newType);
$this->assertSame('Screen Actors Guild Award', $newType->getName());
$this->assertSame('Screen Actors Guild Award', $newType->getPattern());
$award = $persisted[1];
$this->assertInstanceOf(Award::class, $award);
$this->assertSame($newType, $award->getAwardType());
}
public function testDoesNotSetFlagOnWikidataError(): void
{
$actor = $this->createActorWithFlag(awardsImported: false);
$this->wikidataGateway->method('getAwards')
->willThrowException(new \RuntimeException('Wikidata timeout'));
$this->importer->importForActor($actor);
$this->assertFalse($actor->isAwardsImported());
}
public function testHandlesActorWithNoAwards(): void
{
$actor = $this->createActorWithFlag(awardsImported: false);
$this->wikidataGateway->method('getAwards')->willReturn([]);
$this->awardTypeRepository->method('findAll')->willReturn([]);
$this->em->expects($this->never())->method('persist');
$this->importer->importForActor($actor);
$this->assertTrue($actor->isAwardsImported());
}
private function createActorWithFlag(bool $awardsImported): Actor
{
$actor = new Actor();
$actor->setName('Test Actor');
$actor->setAwardsImported($awardsImported);
return $actor;
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
php bin/phpunit tests/Service/AwardImporterTest.php
```
Expected: FAIL — `AwardImporter` class not found.
- [ ] **Step 3: Write the implementation**
Create `src/Service/AwardImporter.php`:
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Gateway\WikidataGateway;
use App\Repository\AwardTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
readonly class AwardImporter
{
public function __construct(
private WikidataGateway $wikidataGateway,
private AwardTypeRepository $awardTypeRepository,
private EntityManagerInterface $em,
private ?LoggerInterface $logger = null,
) {}
public function importForActor(Actor $actor): void
{
if ($actor->isAwardsImported()) {
return;
}
try {
$wikidataAwards = $this->wikidataGateway->getAwards($actor);
} catch (\Throwable $e) {
$this->logger?->warning('Failed to fetch awards from Wikidata', [
'actor' => $actor->getName(),
'error' => $e->getMessage(),
]);
return;
}
$knownTypes = $this->awardTypeRepository->findAll();
foreach ($wikidataAwards as $wikidataAward) {
$awardType = $this->resolveAwardType($wikidataAward['name'], $knownTypes);
$award = new Award();
$award->setName($wikidataAward['name']);
$award->setYear($wikidataAward['year']);
$award->setActor($actor);
$award->setAwardType($awardType);
$this->em->persist($award);
}
$actor->setAwardsImported(true);
}
/**
* @param list<AwardType> $knownTypes
*/
private function resolveAwardType(string $awardName, array &$knownTypes): AwardType
{
foreach ($knownTypes as $type) {
if (str_contains($awardName, $type->getPattern())) {
return $type;
}
}
$newType = new AwardType();
$prefix = $this->extractPrefix($awardName);
$newType->setName($prefix);
$newType->setPattern($prefix);
$this->em->persist($newType);
$knownTypes[] = $newType;
return $newType;
}
private function extractPrefix(string $awardName): string
{
// Extract text before " for " or " pour " (common patterns in award names)
if (preg_match('/^(.+?)\s+(?:for|pour)\s+/i', $awardName, $matches)) {
return trim($matches[1]);
}
return $awardName;
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
```bash
php bin/phpunit tests/Service/AwardImporterTest.php
```
Expected: all 5 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Service/AwardImporter.php tests/Service/AwardImporterTest.php
git commit -m "feat: add AwardImporter service with tests"
```
---
### Task 6: Integrate `AwardImporter` into the import batch handler
**Files:**
- Modify: `src/MessageHandler/ImportFilmsBatchMessageHandler.php`
- [ ] **Step 1: Add `AwardImporter` dependency**
In the constructor, add:
```php
private AwardImporter $awardImporter,
```
Add the import at the top:
```php
use App\Service\AwardImporter;
```
- [ ] **Step 2: Call `AwardImporter` after actor sync**
In the `__invoke` method, after `$this->actorSyncer->syncActorsForMovie($movie);` (line 63) and **before** the existing `$this->em->flush()` (line 77), add:
```php
$this->actorSyncer->syncActorsForMovie($movie);
// Import awards for actors of this movie
foreach ($movie->getActors() as $role) {
$this->awardImporter->importForActor($role->getActor());
}
```
`Movie::getActors()` returns `Collection<MovieRole>`. The `ActorSyncer` persists actors/roles in memory before flush, so the collection is hydrated at this point. The existing `$this->em->flush()` on line 77 will persist both the roles and the new awards in a single flush. The `$this->em->clear()` on line 87 happens after, so all entities are still available.
- [ ] **Step 3: Commit**
```bash
git add src/MessageHandler/ImportFilmsBatchMessageHandler.php
git commit -m "feat: import actor awards during film batch import"
```
---
### Task 7: Update `GameGridGenerator` to use DB for award hints
**Files:**
- Modify: `src/Service/GameGridGenerator.php`
- [ ] **Step 1: Write the failing test**
Create `tests/Service/GameGridGeneratorTest.php` — test only the hint resolution, not the full game generation (which requires DB):
```php
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Entity\GameRow;
use App\Repository\ActorRepository;
use App\Repository\AwardRepository;
use App\Repository\MovieRepository;
use App\Repository\MovieRoleRepository;
use App\Service\GameGridGenerator;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
class GameGridGeneratorTest extends TestCase
{
public function testResolveHintTextForAward(): void
{
$awardType = new AwardType();
$awardType->setName('Oscar')->setPattern('Academy Award');
$actor = new Actor();
$actor->setName('Test Actor');
$award = new Award();
$award->setName('Academy Award for Best Actor');
$award->setYear(2020);
$award->setActor($actor);
$award->setAwardType($awardType);
$awardRepository = $this->createMock(AwardRepository::class);
$awardRepository->method('find')->with(42)->willReturn($award);
$generator = new GameGridGenerator(
$this->createMock(ActorRepository::class),
$this->createMock(MovieRoleRepository::class),
$this->createMock(MovieRepository::class),
$awardRepository,
$this->createMock(EntityManagerInterface::class),
);
$row = new GameRow();
$row->setHintType('award');
$row->setHintData('42');
// Use reflection to test the private resolveHintText method
$method = new \ReflectionMethod($generator, 'resolveHintText');
$result = $method->invoke($generator, $row);
$this->assertSame('Academy Award for Best Actor (2020)', $result);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
php bin/phpunit tests/Service/GameGridGeneratorTest.php
```
Expected: FAIL — constructor signature mismatch (no `AwardRepository` param yet).
- [ ] **Step 3: Update `GameGridGenerator`**
Replace `WikidataGateway` with `AwardRepository` in the constructor. Full changes to `src/Service/GameGridGenerator.php`:
**Replace the imports:**
- Remove: `use App\Gateway\WikidataGateway;`
- Add: `use App\Repository\AwardRepository;`
**Replace the constructor:**
```php
public function __construct(
private readonly ActorRepository $actorRepository,
private readonly MovieRoleRepository $movieRoleRepository,
private readonly MovieRepository $movieRepository,
private readonly AwardRepository $awardRepository,
private readonly EntityManagerInterface $em,
) {}
```
**Replace the `case 'award':` block in `resolveHint()`** (lines 175-185):
```php
case 'award':
$award = $this->awardRepository->findOneRandomByActor($rowActor->getId());
if ($award === null) {
return null;
}
return ['type' => 'award', 'data' => (string) $award->getId()];
```
**Replace the `'award'` case in `resolveHintText()`** (line 203):
```php
'award' => $this->resolveAwardHintText((int) $data),
```
**Add this private method:**
```php
private function resolveAwardHintText(int $awardId): ?string
{
$award = $this->awardRepository->find($awardId);
if ($award === null) {
return null;
}
$text = $award->getName();
if ($award->getYear() !== null) {
$text .= ' (' . $award->getYear() . ')';
}
return $text;
}
```
- [ ] **Step 4: Run the tests**
```bash
php bin/phpunit tests/Service/GameGridGeneratorTest.php
```
Expected: PASS.
- [ ] **Step 5: Run all tests**
```bash
php bin/phpunit
```
Expected: all tests PASS.
- [ ] **Step 6: Commit**
```bash
git add src/Service/GameGridGenerator.php tests/Service/GameGridGeneratorTest.php
git commit -m "feat: use DB awards instead of live Wikidata calls for hint generation"
```
---
### Task 8: Manual verification
- [ ] **Step 1: Verify the schema is in sync**
```bash
php bin/console doctrine:schema:validate
```
Expected: schema is in sync with mapping files.
- [ ] **Step 2: Verify the app boots**
```bash
php bin/console cache:clear
```
Expected: no errors.
- [ ] **Step 3: Run all tests one final time**
```bash
php bin/phpunit
```
Expected: all tests PASS.
- [ ] **Step 4: Final commit if any fixes were needed**
Only if adjustments were made during verification.