6

Dzień 6 z 18

Więcej z modelem

Opublikowany

Rozbudowujemy warstwę modelu: przenosimy zapytania do repozytoriów, poznajemy QueryBuilder, automatyzujemy wygaśnięcie oferty, grupujemy oferty po kategoriach z konfigurowalnym limitem i zabezpieczamy stronę przeterminowanej oferty.

Czego się nauczysz

  • Repository pattern — logika zapytań w repozytorium
  • QueryBuilder — dynamiczne zapytania i filtr aktywnych ofert
  • Automatyczne expiresAt w lifecycle callback (DateTimeImmutable)
  • Oferty pogrupowane po kategoriach na stronie głównej
  • Konfigurowalny limit przez parametr i zmienną globalną Twiga
  • Dynamiczne fixtures — generowanie ofert w pętli
  • Zabezpieczenie strony oferty przez #[MapEntity(expr: …)]

Co zmieniło się w Symfony 8 vs 4.2

  • ServiceEntityRepository generowane przez make:entity zamiast ręcznego EntityRepository
  • QueryBuilder zamiast DQL ze skrótem App:Job (usuniętym w ORM 3)
  • Natywny #[MapEntity(expr: …)] zamiast @Entity z SensioFrameworkExtraBundle
  • DateTimeImmutable i getReference() z klasą (bez merge())

Dzień 6: Więcej z modelem

Strona główna pokazuje wszystkie oferty — także nieaktywne. Zgodnie z historią F1 z Dnia 2 powinna pokazywać tylko aktywne oferty, pogrupowane według kategorii, maksymalnie 10 na kategorię. Dziś przeniesiemy logikę zapytań do repozytoriów, poznamy QueryBuilder, ustawimy automatyczne wygaśnięcie oferty i zabezpieczymy stronę przeterminowanej oferty.

Aktywna oferta to taka, której expiresAt jest w przyszłości. Repozytoria (JobRepository, CategoryRepository) wygenerował już make:entity w Dniu 3 — dziś dodajemy do nich metody.


Logika zapytań należy do repozytorium

W Dniu 4 akcja list() pobierała wszystkie oferty przez findAll(). Zapytania to jednak logika warstwy Modelu, a nie kontrolera. Zgodnie z MVC kontroler ma tylko wołać Model i przekazywać dane do widoku. Dlatego zapytania umieszczamy w repozytorium.

Repozytorium to wzorzec — warstwa abstrakcji nad encją, zawierająca metody do pobierania danych z bazy. W Symfony 8 repozytoria dziedziczą po ServiceEntityRepository i można je wstrzykiwać do kontrolerów i serwisów.

Zmiana vs Symfony 4.2: w oryginale trzeba było ręcznie tworzyć klasę repozytorium (rozszerzającą EntityRepository) i wskazywać ją w adnotacji encji. Dziś make:entity generuje repozytorium rozszerzające ServiceEntityRepository i od razu podpina je do encji (repositoryClass:), a my tylko dopisujemy metody.


QueryBuilder — aktywne oferty

Dodaj metodę findActiveJobs() do istniejącego src/Repository/JobRepository.php (konstruktor i klasę wygenerował make:entity):

<?php

namespace App\Repository;

use App\Entity\Job;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class JobRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Job::class);
    }

    /** @return Job[] */
    public function findActiveJobs(?int $categoryId = null): array
    {
        $qb = $this->createQueryBuilder('j')
            ->andWhere('j.expiresAt > :now')
            ->setParameter('now', new \DateTimeImmutable())
            ->orderBy('j.expiresAt', 'DESC');

        if (null !== $categoryId) {
            $qb->andWhere('j.category = :categoryId')
               ->setParameter('categoryId', $categoryId);
        }

        return $qb->getQuery()->getResult();
    }
}

createQueryBuilder('j') buduje zapytanie DQL programistycznie — czytelnie i bezpiecznie (parametry :now chronią przed SQL injection). Metoda przyjmuje opcjonalny categoryId, co pozwoli użyć jej też na stronie kategorii.

Zmiana vs Symfony 4.2: w oryginale używano DQL ze skróconą składnią SELECT j FROM App:Job j w kontrolerze. Skrót App:Job został usunięty w Doctrine ORM 3 — dziś używamy QueryBuilera (albo pełnej nazwy App\Entity\Job). Zwróć uwagę, że akcja list() z Dnia 4 już wstrzykuje repozytorium — metoda $this->getDoctrine() została usunięta w Symfony 6.


Automatyczne wygaśnięcie oferty

Aby oferta wygasała 30 dni po utworzeniu (o ile nie ustawiono expiresAt ręcznie), rozszerzamy lifecycle callback #[ORM\PrePersist] w src/Entity/Job.php:

#[ORM\PrePersist]
public function setTimestampsOnCreate(): void
{
    $now = new \DateTimeImmutable();
    $this->createdAt = $now;
    $this->updatedAt = $now;

    if (null === $this->expiresAt) {
        $this->expiresAt = $now->modify('+30 days');
    }
}

Ponieważ \DateTimeImmutable jest niemutowalny, modify() zwraca nowy obiekt — przypisujemy go do expiresAt. Nie potrzebujemy już clone, którego wymagał mutowalny \DateTime w oryginale.


Podgląd zapytań SQL

Warto widzieć SQL generowany przez Doctrine — np. przy diagnozie wolnego zapytania. W środowisku dev służy do tego Web Debug Toolbar: ikona Doctrine na dolnym pasku pokazuje liczbę zapytań, a po kliknięciu — pełny SQL, parametry i czas wykonania każdego z nich (http://localhost:8090/_profiler, zakładka Doctrine).


Kategorie na stronie głównej

Strona główna ma grupować oferty według kategorii — i pokazywać tylko kategorie z co najmniej jedną aktywną ofertą. Dodaj metodę do src/Repository/CategoryRepository.php:

/** @return Category[] */
public function findWithActiveJobs(): array
{
    return $this->createQueryBuilder('c')
        ->innerJoin('c.jobs', 'j')
        ->andWhere('j.expiresAt > :now')
        ->setParameter('now', new \DateTimeImmutable())
        ->distinct()
        ->getQuery()
        ->getResult();
}

innerJoin łączy kategorie z ofertami, a distinct() zapobiega duplikatom kategorii (gdy kategoria ma wiele aktywnych ofert).

Metoda zwraca kategorie, ale wywołanie category.jobs w szablonie dałoby wszystkie oferty, także przeterminowane. Dodajmy więc do encji Category metodę filtrującą tylko aktywne:

use Doctrine\Common\Collections\Collection;

/** @return Collection<int, Job> */
public function getActiveJobs(): Collection
{
    $now = new \DateTimeImmutable();

    return $this->jobs->filter(fn (Job $job): bool => $job->getExpiresAt() > $now);
}

Aktualizacja kontrolera i szablonu

Zmień akcję list() w JobController, aby pobierała kategorie z aktywnymi ofertami:

use App\Repository\CategoryRepository;

#[Route('/', name: 'job_list', methods: ['GET'])]
public function list(CategoryRepository $categories): Response
{
    return $this->render('job/list.html.twig', [
        'categories' => $categories->findWithActiveJobs(),
    ]);
}

Zaktualizuj templates/job/list.html.twig, aby iterować po kategoriach i wyświetlać ich aktywne oferty:

{% extends 'base.html.twig' %}

{% block title %}Jobeet — oferty pracy{% endblock %}

{% block body %}
    {% for category in categories %}
        <h2 class="h4 mt-4">{{ category.name }}</h2>

        <table class="table table-hover text-center align-middle">
            <thead>
                <tr>
                    <th class="text-center">Lokalizacja</th>
                    <th class="text-center">Stanowisko</th>
                    <th class="text-center">Firma</th>
                </tr>
            </thead>
            <tbody>
                {% for job in category.activeJobs %}
                    <tr>
                        <td>{{ job.location }}</td>
                        <td><a href="{{ path('job_show', { id: job.id }) }}">{{ job.position }}</a></td>
                        <td>{{ job.company }}</td>
                    </tr>
                {% endfor %}
            </tbody>
        </table>
    {% endfor %}
{% endblock %}

W Twigu category.activeJobs wywołuje metodę getActiveJobs() z encji.


Limit ofert na kategorię

Historia F1 wymaga maksymalnie 10 ofert na kategorię. Najprościej ograniczyć to filtrem slice:

{% for job in category.activeJobs|slice(0, 10) %}

Konfigurowalny limit

Zaszyta na sztywno „10” to zły pomysł. Zdefiniujmy parametr w config/services.yaml:

parameters:
    max_jobs_on_homepage: 10

Aby był dostępny w szablonach, udostępnijmy go jako zmienną globalną Twiga w config/packages/twig.yaml:

twig:
    globals:
        max_jobs_on_homepage: '%max_jobs_on_homepage%'

Teraz w szablonie:

{% for job in category.activeJobs|slice(0, max_jobs_on_homepage) %}

Dynamiczne fixtures

Przy dwóch ofertach nie zobaczymy działania limitu. Wygenerujmy więcej ofert w pętli oraz jedną przeterminowaną — w src/DataFixtures/JobFixtures.php, w metodzie load():

// oferta przeterminowana (nie powinna pojawić się na stronie głównej)
$expired = (new Job())
    ->setCategory($this->getReference(CategoryFixtures::PROGRAMMING, Category::class))
    ->setType('full-time')
    ->setCompany('Sensio Labs')
    ->setPosition('Web Developer (expired)')
    ->setLocation('Paris, France')
    ->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.')
    ->setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit')
    ->setPublic(true)
    ->setActivated(true)
    ->setToken('job_expired')
    ->setEmail('job@example.com')
    ->setExpiresAt(new \DateTimeImmutable('-10 days'));
$manager->persist($expired);

// 30 aktywnych ofert (expiresAt ustawi automatycznie prePersist)
for ($i = 1; $i <= 30; $i++) {
    $job = (new Job())
        ->setCategory($this->getReference(CategoryFixtures::PROGRAMMING, Category::class))
        ->setType('full-time')
        ->setCompany(sprintf('Company %d', $i))
        ->setPosition('Web Developer')
        ->setLocation('Paris, France')
        ->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.')
        ->setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit')
        ->setPublic(true)
        ->setActivated(true)
        ->setToken(sprintf('job_%d', $i))
        ->setEmail('job@example.com');
    $manager->persist($job);
}

$manager->flush();

Przeładuj dane i sprawdź stronę główną — kategoria „Programming” pokaże maksymalnie 10 ofert, a oferta przeterminowana się nie pojawi:

docker compose exec app php bin/console doctrine:fixtures:load --no-interaction

Zmiana vs Symfony 4.2: referencje pobieramy przez getReference(ref, Category::class) (drugi argument z klasą), bez usuniętego $manager->merge(...). Daty to \DateTimeImmutable.


Zabezpieczenie strony oferty

Gdy oferta wygaśnie, nie powinno dać się jej otworzyć nawet znając URL. Dodaj metodę findActiveJob() do JobRepository:

public function findActiveJob(int $id): ?Job
{
    return $this->createQueryBuilder('j')
        ->andWhere('j.id = :id')
        ->andWhere('j.expiresAt > :now')
        ->setParameter('id', $id)
        ->setParameter('now', new \DateTimeImmutable())
        ->getQuery()
        ->getOneOrNullResult();
}

Następnie w JobController powiedz resolverowi encji, aby użył tej metody — przez atrybut #[MapEntity] z wyrażeniem:

use App\Entity\Job;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;

#[Route('/job/{id}', name: 'job_show', methods: ['GET'], requirements: ['id' => '\d+'])]
public function show(
    #[MapEntity(expr: 'repository.findActiveJob(id)')] Job $job,
): Response {
    return $this->render('job/show.html.twig', [
        'job' => $job,
    ]);
}

Gdy findActiveJob() zwróci null (oferta wygasła lub nie istnieje), Symfony automatycznie pokaże stronę 404.

Zmiana vs Symfony 4.2: w oryginale służyła do tego adnotacja @Entity("job", expr="…") z SensioFrameworkExtraBundle. Dziś używamy natywnego atrybutu #[MapEntity(expr: '…')] z symfony/doctrine-bridge — bez zewnętrznego bundla.


Podsumowanie

Strona główna działa zgodnie ze specyfikacją:

  • ✅ logika zapytań przeniesiona do repozytoriów (ServiceEntityRepository),
  • ✅ QueryBuilder zamiast DQL ze skrótem App:Job (usuniętym w ORM 3),
  • ✅ automatyczne expiresAt (+30 dni) w lifecycle callback,
  • ✅ oferty pogrupowane po kategoriach, tylko aktywne, limit z konfigurowalnego parametru,
  • ✅ dynamiczne fixtures (pętla) z automatycznym wygaśnięciem,
  • ✅ zabezpieczenie strony oferty przez #[MapEntity(expr: …)] (404 dla wygasłych).

W Dniu 7 zbudujemy pełną stronę kategorii z listą wszystkich ofert, paginacją i rozwiążemy problem N+1 zapytań.

Narzędzia / paczki

Doctrine QueryBuilder ServiceEntityRepository MapEntity

Spis treści