Uczymy się jak działa lazy loading i eager loading w Symfony oraz jak napisać własną metodę dla Repozytorium. Do dzieła.

Ok, trochę czasu i debugowania (jakieś 15 min) mi zajęło zanim sam to pojąłem. Najpierw przypomnienie w czym rzecz na przykładzie Laravela:

$posts = Post::all();
foreach ($posts as $post) {
    $comments = $post->comments; // A separate query for each post to load comments
}

Tutaj mamy zaciągnięcie wszystkich postów, które domyślnie nie mają dociąganych relacji (np. komentarze), żeby uniknąć tzw. N+1 Problem (niepotrzebnego joinu w selekcie i ładowania za każdym razem wszystkich relacji).

Gdybyśmy chcieli wykonać eager-loading, czyli od razu wszystkie posty załadować wraz z jakąś relacją, zrobilibyśmy to tak:

$posts = Post::with('comments')->get();

Powinniśmy też pamiętać withCount, withAvg i inne takie. Cóż, Laravel jest bardzo prosty jeżeli chodzi o Eloquent ORM i ogólnie fasady i helpery, jakie dostarcza.

I chyba koniec końców oba frameworki działają podobnie – to jest domyślnie robią lazy loading, jeżeli wykryją że chcemy odnieść się do relacji, wtedy eager loading.

Tak w każdym razie działa Symfony.

Zobaczmy w czym rzecz:

#[Route('/micro_post', name: 'app_micro_post')]
    public function index(MicroPostRepository $repository): Response
    {
        dd($repository->findAll());
        return $this->render('micro_post/index.html.twig', [
            'posts' => $repository->findAll(),
        ]);
    }

Teraz pod comments znajdziemy persistentCollection, ale wartości będą puste. Ok, dodajmy do repozytorium taką metodę:

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

    public function findAllWithComments(): array
    {
        return $this->createQueryBuilder('p')
            ->addSelect('c')
            ->leftJoin('p.comments', 'c')
            ->orderBy('p.id', 'ASC')
            ->getQuery()
            ->getResult();
    }

Teraz użyjemy jej sobie w metodzie:

 #[Route('/micro_post', name: 'app_micro_post')]
    public function index(MicroPostRepository $repository): Response
    {
        dd($repository->findAllWithComments());
        return $this->render('micro_post/index.html.twig', [
            'posts' => $repository->findAllWithComments(),
        ]);
    }

I mamy eager loading, wartości comments załadowane. Której użyjemy nie ma natomiast znaczenia, gdy gdzieś w kodzie zażyczymy sobie tych comments.

Zróbmy to w index.html.twig:

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

{% block title %}Hello MicroPostController!{% endblock %}

{% block body %}
<style>
    .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
    .example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>

<div class="example-wrapper">
    {% if posts|length > 0 %}
    {% for post in posts %}
      <h3><a href="{{ path('app_micro_post_show', { post: post.id }) }}">{{ post.title }}<a/></h3>
      <p>{{post.content}}</p>
      <p>Comments ({{post.comments.count}}):</p>
       {% for comment in post.comments %}
       <p>{{comment.content}}</p>
      {% endfor %}
      
    {% endfor %}
  {% else %}
            <p>No posts</p>
    {% endif %}
</div>
{% endblock %}

Nasze findAllWithComments będzie działać. Podobnie jak findAll:

#[Route('/micro_post', name: 'app_micro_post')]
    public function index(MicroPostRepository $repository): Response
    {
        
        return $this->render('micro_post/index.html.twig', [
            'posts' => $repository->findAll(),
        ]);
    }

I te relacje będą dociągnięte, bo ich sobie życzymy, ale gdybyśmy nie korzystali z tego, byłby lazy loading.

Ok, zdebugujmy sobie auto-wiring:

#[Route('/micro_post/{post}', name: 'app_micro_post_show')]
    public function showOne(MicroPost $post): Response
    {   
        return $this->render('micro_post/single.html.twig', ['post' => $post]);
    }

Elements będzie 0 (w comments), ale w momencie zażyczenia sobie zostaną dociągnięte.

Nowe single.html.twig:

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

{% block title %}Hello MicroPostController!{% endblock %}

{% block body %}
<style>
    .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
    .example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>

<div class="example-wrapper">
    {% if post %}
    
      <h3>{{ post.title }}</h3>
      <p>{{post.content}}</p>
      <a href="{{ path('app_micro_post_comment', {post: post.id}) }}">Comment ({{post.comments.count}})</a>
      {% for comment in post.comments %}
       <p>{{comment.content}}</p>
      {% endfor %}

   {% else %}
            <p>No such post</p>
    {% endif %}
</div>
{% endblock %}

Mało tego – jeżeli zostawimy sobie dd post to zobaczymy, że w tym jeszcze momencie elements (w comments) jest puste. Dopiero potem mamy widok, który chce tych relacji, więc dopiero wtedy zostają dociągnięte.

Oczywiście doładowanie tych comments wcześniej możemy wymusić:

#[Route('/micro_post/{post}', name: 'app_micro_post_show')]
    public function showOne(MicroPost $post): Response
    {   
        $postCommentCount = $post->getComments()->count();
        dd($post);
        return $this->render('micro_post/single.html.twig', ['post' => $post]);
    }

Przed dd korzystamy z getComments, aby przypisać ilość komentów do zmiennej, której w ogóle nie użyjemy. To sprawia, że w momencie, w którym robimy dd post tak już pod comments pod values nie ma pustej listy, wszystko dociągnięte.

Analogicznie, można też stworzyć w Repo metodę findOneWithComments, która zawsze zrobi eager loading, ale tutaj auto-wiring omawiamy.

A o co chodzi w N + 1? Najlepiej tłumaczy to dokumentacja Laravela:

use App\Models\Book;
 
$books = Book::all();
 
foreach ($books as $book) {
    echo $book->author->name;
}

Tutaj mamy dla przykładu 25 książek w bazie danych. Ten kod wygeneruje 1 query (do pobrania wszystkich książek) i 25 queries do pobrania każdej relacji (każdego autora).

Tutaj robimy eager-loading:

$books = Book::with('author')->get();
 
foreach ($books as $book) {
    echo $book->author->name;
}

I mamy 2 query, jedno na książki, drugie na ich relacje (autor). Ładujemy więcej, queries robimy mniej, dużo mniej. I o to mniej więcej w tym chodzi.

PS. Czy lazy loading i „dociąganie” działa na tych wynikach, które napisaliśmy używając query buildera? Aż musiałem to sprawdzić.

Jakiś czas temu napisaliśmy taką metodę:

 #[Route('/querybuilder', name: 'app_micro_post_qb')]
    public function qb(MicroPostRepository $repository): Response
    {
        $posts_filtered = $repository->createQueryBuilder("m")
        ->where("m.id > 1")
        ->getQuery()
        ->execute();

        return $this->render('micro_post/index.html.twig', [
            'posts' => $posts_filtered,
        ]);
    }

I tak, są dociągane.

Kiedy natomiast dociąganie się wysypie? Jak użyjemy select from:

#[Route('/querybuilder2', name: 'app_micro_post_qb_2')]
    public function qb2(EntityManagerInterface $em): Response
    {

        $posts_filtered = $em->createQueryBuilder()
        ->select('m.id, m.title, m.content')
        ->from(MicroPost::class, 'm')
        ->where("m.id > 1")
        ->getQuery()
        ->getResult();

        //dd($posts_filtered);
        

        return $this->render('micro_post/index.html.twig', [
            'posts' => $posts_filtered,
        ]);
    }

Wysypie się, bo nie mamy pola count, robiąc taki select w zasadzie działamy tak, jak byśmy z palca SQL pisali i tutaj już nam nie zadziała dociąganie.