If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeVuelve a la página de inicio sin consulta de búsqueda. Todavía tenemos siete consultas porque seguimos utilizando nuestro método muy simple findAllOrdered()
... que no tiene el JOIN
. Así que... deberíamos añadir el JOIN
aquí también, ¿no? Sí, bueno... probablemente. Pero quiero mostrarte una solución alternativa.
Nuestra página de inicio es única porque en realidad no necesitamos todos los datos de FortuneCookie
para cada Category
... lo único que necesitamos es el COUNT
.
Fíjate en la plantilla: no estamos haciendo un bucle sobre category.fortuneCookies
y mostrando los datos reales de FortuneCookie
. No, simplemente los estamos contando. Si lo piensas, tener una consulta gigante que coge todos los datos de FortuneCookie
.... sólo para contarlos... no es lo mejor para la eficiencia.
... lines 1 - 7 | |
{% for category in categories %} | |
<a class="bg-orange-400 hover:bg-orange-500 text-white text-center rounded-full p-4" href="{{ path('app_category_show', {'id': category.id}) }}"> | |
<span class="fa {{ category.iconKey }}"></span> <span class="font-bold text-lg">{{ category.name }}</span> ({{ category.fortuneCookies|length }}) | |
</a> | |
... lines 12 - 13 | |
{% endfor %} | |
... lines 15 - 17 |
Si te encuentras en esta situación, puedes decirle a Doctrine que sea inteligente con la forma en que carga la relación. Entra en la entidad Category
y busca la relación OneToMany
para $fortuneCookies
. Al final, añade fetch:
fijado en EXTRA_LAZY
.
... lines 1 - 10 | |
class Category | |
{ | |
... lines 13 - 23 | |
#[ORM\OneToMany(mappedBy: 'category', targetEntity: FortuneCookie::class, fetch: 'EXTRA_LAZY')] | |
private Collection $fortuneCookies; | |
... lines 26 - 89 | |
} |
Vamos a ver qué hace eso. Cuando actualices, observa el recuento de consultas. ¡Se queda en siete! Pero si abrimos el perfilador, las consultas en sí han cambiado. La primera es la misma: consulta desde category
. ¡Pero fíjate en las demás! ¡Tenemos SELECT COUNT(*) FROM fortune_cookie
una y otra vez! Así que tenemos siete consultas, ¡pero ahora cada una sólo selecciona el COUNT
!
Cuando tienes fetch: 'EXTRA_LAZY'
y simplemente cuentas una relación de colección, Doctrine es lo suficientemente inteligente como para seleccionar sólo el COUNT
en lugar de consultar todos los datos. Si hiciéramos un bucle sobre esta colección y empezáramos a imprimir los datos deFortuneCookie
, seguiría haciendo una consulta completa de los datos. Pero si lo único que necesitamos es contarlos, entonces fetch: 'EXTRA_LAZY'
es una gran solución.
Vale: haz clic en una de las categorías. El perfilador dice que tenemos dos consultas. Se trata de una especie de problema N+1 en "miniatura". La primera consulta selecciona un únicoCategory
... y la segunda selecciona todas las galletas de la suerte de esta única categoría. Utilicemos nuestras habilidades en JOIN
para reducirlo a una sola consulta.
Abre FortuneController
y busca la acción showCategory()
. Al escribirCategory
en este argumento, le estamos diciendo a Symfony que busque Category
por nosotros, utilizando {id}
. Normalmente, ¡esto me encanta! Sin embargo, en este caso, como queremos añadir un JOIN
de Category
a fortuneCookies
, tenemos que tomar el control de esa consulta.
... lines 1 - 11 | |
class FortuneController extends AbstractController | |
{ | |
... lines 14 - 29 | |
public function showCategory(Category $category): Response | |
{ | |
return $this->render('fortune/showCategory.html.twig',[ | |
'category' => $category | |
]); | |
} | |
} |
Cambia esto para que Symfony nos pase el int $id
directamente. Luego, autocableaCategoryRepository $categoryRepository
.
... lines 1 - 11 | |
class FortuneController extends AbstractController | |
{ | |
... lines 14 - 29 | |
public function showCategory(int $id, CategoryRepository $categoryRepository): Response | |
{ | |
... lines 32 - 36 | |
} | |
} |
A continuación, haz la consulta manualmente con $category = $categoryRepository->
... llamando a un nuevo método: findWithFortunesJoin($id)
. Antes de crearlo, también tenemos que añadir if (!$category)
, y luego throw $this->createNotFoundException()
. Si quieres, puedes darle un mensaje.
Vale, copia el nombre del método, salta a CategoryRepository
y dipublic function findWithFortunesJoin(int $id)
, que devolverá un Category
si se encuentra alguno, si no null
. Arreglaré la errata en un momento.
... lines 1 - 29 | |
public function showCategory(int $id, CategoryRepository $categoryRepository): Response | |
{ | |
$category = $categoryRepository->findWithFortunesJoin($id); | |
if (!$category) { | |
throw $this->createNotFoundException('Category not found!'); | |
} | |
... lines 36 - 39 | |
} | |
... lines 41 - 42 |
La consulta empieza como la otra.... y podríamos robar algo de código... pero como estamos practicando, vamos a escribirla a mano. return $this->createQueryBuilder()
y pasar nuestro alias normal category
. Luego ->andWhere('category.id = :id')
-también arreglaré esa errata en un minuto- rellenando el comodín con ->setParameter()
id
, $id
... idealmente escrito correctamente. Luego ->getQuery()
.
... lines 1 - 17 | |
class CategoryRepository extends ServiceEntityRepository | |
{ | |
... lines 20 - 52 | |
public function findWithFortunesJoin(int $id): ?Category | |
{ | |
return $this->createQueryBuilder('category') | |
... lines 56 - 57 | |
->andWhere('category.id = :id') | |
->setParameter('id', $id) | |
->getQuery() | |
... line 61 | |
} | |
... lines 63 - 105 | |
} |
Hasta ahora, hemos estado buscando varias filas... y por eso hemos utilizado ->getResult()
. Pero esta vez, queremos un único resultado o null si no se puede encontrar. Para ello, utiliza ->getOneOrNullResult()
.
... lines 1 - 52 | |
public function findWithFortunesJoin(int $id): ?Category | |
{ | |
return $this->createQueryBuilder('category') | |
... lines 56 - 60 | |
->getOneOrNullResult(); | |
} | |
... lines 63 - 107 |
Y ya está Con esto deberían funcionar las cosas. Haré una pequeña comprobación de cordura por aquí, y... oh... probablemente ayudaría si escribiera las cosas correctamente. ¡Pero esto es genial! Ha reconocido que no sabía qué era ese alias y nos ha dado un error claro. Y ahora... funciona, y seguimos teniendo dos consultas.
¡Ha llegado la hora de JOIN
! Vamos a pasar de una Category
a muchas galletas de la fortuna, así que digamos ->leftJoin()
sobre category.
y el nombre de la propiedad, que esfortuneCookies
. Una vez más, el orden no importa, pero arriba diré->addSelect('fortuneCookie')
. Ah, y también tengo que añadir fortuneCookie
como segundo argumento dentro de ->leftJoin()
: ése es el alias.
... lines 1 - 52 | |
public function findWithFortunesJoin(int $id): ?Category | |
{ | |
return $this->createQueryBuilder('category') | |
->addSelect('fortuneCookie') | |
->leftJoin('category.fortuneCookies', 'fortuneCookie') | |
... lines 58 - 61 | |
} | |
... lines 63 - 107 |
Así que estamos poniendo el alias de esa entidad unida en fortuneCookie
y luego seleccionando fortuneCookie
. Ahora, deberíamos ver que el número de esta consulta pasa de dos a uno. Y... ¡así ha sido!
Éstas son las conclusiones: aunque no hay necesidad de sobreoptimizar, si tienes el problema N+1, puedes resolverlo uniéndote a la tabla relacionada y seleccionando sus datos.
Vale, hasta ahora Doctrine devolvía una colección de objetos Category
o un único objetoCategory
. Eso está muy bien, pero ¿y si, en lugar de objetos enteros, sólo necesitamos algunos datos, como unas cuantas columnas, un COUNT
, o un SUM
? Vamos a profundizar en ello a continuación.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"beberlei/doctrineextensions": "^1.3", // v1.3.0
"doctrine/doctrine-bundle": "^2.7", // 2.9.1
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.13", // 2.15.1
"symfony/asset": "6.2.*", // v6.2.7
"symfony/console": "6.2.*", // v6.2.10
"symfony/dotenv": "6.2.*", // v6.2.8
"symfony/flex": "^2", // v2.2.5
"symfony/framework-bundle": "6.2.*", // v6.2.10
"symfony/proxy-manager-bridge": "6.2.*", // v6.2.7
"symfony/runtime": "6.2.*", // v6.2.8
"symfony/twig-bundle": "6.2.*", // v6.2.7
"symfony/webpack-encore-bundle": "^1.16", // v1.16.1
"symfony/yaml": "6.2.*" // v6.2.10
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.4
"symfony/maker-bundle": "^1.47", // v1.48.0
"symfony/stopwatch": "6.2.*", // v6.2.7
"symfony/web-profiler-bundle": "6.2.*", // v6.2.10
"zenstruck/foundry": "^1.22" // v1.32.0
}
}