Files
ltbxd-actorle/docs/superpowers/plans/2026-04-01-awards-bdd.md
thibaud-leclere 76013afb1c docs: add implementation plan for awards in database
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:20:20 +02:00

23 KiB

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

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

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
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

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

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
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:

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

$this->awards = new ArrayCollection();

Add these methods at the end of the class:

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
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

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

php bin/console doctrine:migrations:migrate --no-interaction

Expected: migration executes successfully.

  • Step 4: Commit
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

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
php bin/phpunit tests/Service/AwardImporterTest.php

Expected: FAIL — AwardImporter class not found.

  • Step 3: Write the implementation

Create src/Service/AwardImporter.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
php bin/phpunit tests/Service/AwardImporterTest.php

Expected: all 5 tests PASS.

  • Step 5: Commit
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:

private AwardImporter $awardImporter,

Add the import at the top:

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:

$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
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

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
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:

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):

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):

'award' => $this->resolveAwardHintText((int) $data),

Add this private method:

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
php bin/phpunit tests/Service/GameGridGeneratorTest.php

Expected: PASS.

  • Step 5: Run all tests
php bin/phpunit

Expected: all tests PASS.

  • Step 6: Commit
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
php bin/console doctrine:schema:validate

Expected: schema is in sync with mapping files.

  • Step 2: Verify the app boots
php bin/console cache:clear

Expected: no errors.

  • Step 3: Run all tests one final time
php bin/phpunit

Expected: all tests PASS.

  • Step 4: Final commit if any fixes were needed

Only if adjustments were made during verification.