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.