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 SubscribeLa página /browse
funciona... ¿pero qué pasa si hacemos clic en uno de estos géneros? Bueno... eso funciona más o menos. Muestra el nombre del género... pero obtenemos una lista de todas las mezclas. Lo que realmente queremos es filtrarlas para que sólo se muestren las mezclas de ese género concreto.
Ahora mismo, todas las mezclas de la base de datos están en el género "Pop". Vuelve aMixController
y encuentra el método falso que crea nuevas mezclas para que podamos hacer algunos datos ficticios más interesantes. Añade una variable $genres
con "Pop" y "Rock" incluidos... Luego selecciona una al azar con $genres[array_rand($genres)]
.
... lines 1 - 10 | |
class MixController extends AbstractController | |
{ | |
... line 13 | |
public function new(EntityManagerInterface $entityManager): Response | |
{ | |
... lines 16 - 18 | |
$genres = ['pop', 'rock']; | |
$mix->setGenre($genres[array_rand($genres)]); | |
... lines 21 - 31 | |
} | |
} |
¡Genial! Ahora ve a /mix/new
y actualiza unas cuantas veces... hasta que tengamos unas 15 mezclas. Volvemos a /browse
... ¡yup! Tenemos una mezcla de géneros "Rock" y "Pop"... sólo que aún no se filtran.
Así que nuestra misión está clara: personalizar la consulta de la base de datos para que sólo devuelva los resultados de un género concreto. Bien, en realidad podemos hacerlo de forma súper sencilla en VinylController
a través del método findBy()
. El género está en la URL como el comodín $slug
.
Así que podríamos añadir una sentencia "if" en la que, si hay un género, devolvamos todos los resultados en los que genre
coincida con $slug
. Pero esta es una gran oportunidad para aprender a crear una consulta personalizada. Así que vamos a deshacerlo.
La mejor manera de hacer una consulta personalizada, es crear un nuevo método en el repositorio para la entidad de la que quieras obtener datos. En este caso, eso significaVinylMixRepository
. Esto contiene algunos métodos de ejemplo. Descomenta el primero... y empieza de forma sencilla
... lines 1 - 16 | |
class VinylMixRepository extends ServiceEntityRepository | |
{ | |
... lines 19 - 41 | |
/** | |
* @return VinylMix[] Returns an array of VinylMix objects | |
*/ | |
public function findByExampleField($value): array | |
{ | |
return $this->createQueryBuilder('v') | |
->andWhere('v.exampleField = :val') | |
->setParameter('val', $value) | |
->orderBy('v.id', 'ASC') | |
->setMaxResults(10) | |
->getQuery() | |
->getResult() | |
; | |
} | |
... lines 56 - 65 | |
} |
Llámalo findAllOrderedByVotes()
. No nos preocuparemos todavía del género: Sólo quiero hacer una consulta que devuelva todas las mezclas ordenadas por votos. Quitando el argumento, esto devolverá un array y el PHPdoc anterior ayuda a mi editor a saber que será un array de objetos VinylMix
... lines 1 - 41 | |
/** | |
* @return VinylMix[] Returns an array of VinylMix objects | |
*/ | |
public function findAllOrderedByVotes(): array | |
{ | |
... lines 47 - 51 | |
} | |
... lines 53 - 64 |
Hay varias formas de ejecutar una consulta personalizada en Doctrine. Doctrine, por supuesto, acaba realizando consultas SQL. Pero Doctrine trabaja con MySQL, Postgres y otros motores de bases de datos... y el SQL necesario para cada uno de ellos es ligeramente diferente.
Para manejar esto, internamente, Doctrine tiene su propio lenguaje de consulta llamado Doctrine Query Language o "DQL", Tiene un aspecto similar a
SELECT v FROM App\Entity\VinylMix v WHERE v.genre = 'pop';
Puedes escribir estas cadenas a mano, pero yo aprovecho el "QueryBuilder" de Doctrine: un bonito objeto que ayuda... ya sabes... ¡a construir esa consulta!
Para utilizarlo, empieza con $this->createQueryBuilder()
y pasa un alias que se utilizará para identificar esta clase dentro de la consulta. Hazlo corto, pero único entre tus entidades - algo como mix
.
... lines 1 - 44 | |
public function findAllOrderedByVotes(): array | |
{ | |
return $this->createQueryBuilder('mix') | |
... lines 48 - 51 | |
} | |
... lines 53 - 64 |
Como estamos llamando a esto desde dentro de VinylMixRepository
, el QueryBuilder ya sabe que hay que consultar desde la entidad VinylMix
... y utilizará mix
como alias. Si ejecutáramos este query builder ahora mismo, sería básicamente:
SELECT * FROM vinyl_mix AS mix
El constructor de consultas está cargado de métodos para controlar la consulta. Por ejemplo, llama a ->orderBy()
y pasa a mix
-ya que es nuestro alias- .votes
y luegoDESC
.
... lines 1 - 44 | |
public function findAllOrderedByVotes(): array | |
{ | |
return $this->createQueryBuilder('mix') | |
->orderBy('mix.votes', 'DESC') | |
... lines 49 - 51 | |
} | |
... lines 53 - 64 |
Ya está Ahora que nuestra consulta está construida, para ejecutarla llama a ->getQuery()
(que la convierte en un objeto Query
) y luego a ->getResult()
.
... lines 1 - 44 | |
public function findAllOrderedByVotes(): array | |
{ | |
return $this->createQueryBuilder('mix') | |
... line 48 | |
->getQuery() | |
->getResult() | |
; | |
} | |
... lines 53 - 64 |
Bueno, en realidad, hay varios métodos a los que puedes llamar para obtener los resultados. Los dos principales son getResult()
-que devuelve una matriz de los objetos coincidentes- o getOneOrNullResult()
, que es el que utilizarías si estuvieras consultando un VinylMix
específico o nulo. Como queremos devolver una matriz de mezclas coincidentes, utiliza getResult()
.
Ahora podemos utilizar este método. En VinylController
(déjame cerrar MixController
...), en lugar de findBy()
, llama a findAllOrderedByVotes()
.
... lines 1 - 10 | |
class VinylController extends AbstractController | |
{ | |
... lines 13 - 36 | |
public function browse(VinylMixRepository $mixRepository, string $slug = null): Response | |
{ | |
... lines 39 - 40 | |
$mixes = $mixRepository->findAllOrderedByVotes(); | |
... lines 42 - 46 | |
} | |
} |
Me encanta lo claro que es este método: hace que sea súper obvio lo que estamos consultando exactamente. Y cuando lo probamos... ¡sigue funcionando! Todavía no está filtrando, pero el orden es correcto.
Bien, volvamos a nuestro nuevo método. Añade un argumento opcional string $genre = null
. Si se pasa un género, tenemos que añadir una sentencia "where". Para hacer espacio para ello, divide esto en varias líneas... y sustituye return
por $queryBuilder =
. A continuación, return $queryBuilder
por ->getQuery()
, y ->getResult()
.
... lines 1 - 16 | |
class VinylMixRepository extends ServiceEntityRepository | |
{ | |
... lines 19 - 44 | |
public function findAllOrderedByVotes(string $genre = null): array | |
{ | |
$queryBuilder = $this->createQueryBuilder('mix') | |
->orderBy('mix.votes', 'DESC'); | |
return $queryBuilder | |
->getQuery() | |
->getResult() | |
; | |
} | |
... lines 55 - 64 | |
} |
Ahora podemos decir if ($genre)
, y añadir la declaración "where". ¿Cómo? Apuesto a que puedes adivinar: $queryBuilder->andWhere()
.
Pero una advertencia. También hay un método where()
... pero yo nunca lo uso. Cuando llames a where()
, borrará cualquier sentencia "where" existente que pueda tener el constructor de consultas... por lo que podrías eliminar accidentalmente algo que hayas añadido antes. Por tanto, utiliza siempre andWhere()
. Doctrine es lo suficientemente inteligente como para darse cuenta de que, al ser el primer WHERE, no necesita añadir elAND
.
Dentro de andWhere()
, pasa mix.genre
=... pero no pongas el género dinámico justo en la cadena. Eso es un gran no-no: nunca lo hagas. Eso te abre a los ataques de inyección SQL. En su lugar, siempre que necesites poner un valor dinámico en una consulta, utiliza una "sentencia preparada"... que es una forma elegante de decir que pones un marcador de posición aquí, como :genre
. El nombre de esto puede ser cualquier cosa... como "dinosaurio" si quieres. Pero lo llames como lo llames, luego rellenarás el marcador de posición diciendo ->setParameter()
con el nombre del parámetro -así quegenre
- y luego el valor: $genre
.
... lines 1 - 44 | |
public function findAllOrderedByVotes(string $genre = null): array | |
{ | |
... lines 47 - 49 | |
if ($genre) { | |
$queryBuilder->andWhere('mix.genre = :genre') | |
->setParameter('genre', $genre); | |
} | |
... lines 54 - 58 | |
} | |
... lines 60 - 71 |
¡Qué bonito! De nuevo en VinylController
, pasa $slug
como género.
¡Vamos a probar esto! Vuelve a la página de navegación primero. ¡Genial! Obtenemos todos los resultados. Ahora haz clic en "Rock" y... ¡bien! Menos resultados y todos los géneros muestran "Rock"! Si filtro por "Pop"... ¡lo tengo! Incluso podemos ver la consulta para esto... aquí está. Tiene la sentencia "where" para el género igual a "Pop". ¡Guau!
A medida que tu proyecto se hace más y más grande, vas a crear más y más métodos en tu repositorio para las consultas personalizadas. Y puede que empieces a repetir la misma lógica de consulta una y otra vez. Por ejemplo, podríamos ordenar por los votos en un montón de métodos diferentes de esta clase.
Para evitar la duplicación, podemos aislar esa lógica en un método privado. ¡Compruébalo! Añade private function addOrderByVotesQueryBuilder()
. Esto aceptará un argumentoQueryBuilder
(queremos el de Doctrine\ORM
), pero hagámoslo opcional. Y también devolveremos un QueryBuilder
.
... lines 1 - 17 | |
class VinylMixRepository extends ServiceEntityRepository | |
{ | |
... lines 20 - 60 | |
private function addOrderByVotesQueryBuilder(QueryBuilder $queryBuilder = null): QueryBuilder | |
{ | |
... lines 63 - 65 | |
} | |
... lines 67 - 76 | |
} |
El trabajo de este método es añadir esta línea ->orderBy()
. Y por comodidad, si no pasamos un $queryBuilder
, crearemos uno nuevo.
Para permitirlo, empieza con$queryBuilder = $queryBuilder ??
$this->createQueryBuilder('mix'). Vuelvo a utilizar a propósito mix
para el alias. Para simplificar la vida, elige un alias para una entidad y utilízalo sistemáticamente en todas partes.
... lines 1 - 60 | |
private function addOrderByVotesQueryBuilder(QueryBuilder $queryBuilder = null): QueryBuilder | |
{ | |
$queryBuilder = $queryBuilder ?? $this->createQueryBuilder('mix'); | |
... lines 64 - 65 | |
} | |
... lines 67 - 78 |
En cualquier caso, esta línea puede parecer extraña, pero básicamente dice
Si existe un QueryBuilder, utilízalo. Si no, crea uno nuevo.
Debajo de return $queryBuilder
... ve a robar la lógica de ->orderBy()
de aquí arriba y... pega. ¡Impresionante!
... lines 1 - 60 | |
private function addOrderByVotesQueryBuilder(QueryBuilder $queryBuilder = null): QueryBuilder | |
{ | |
... lines 63 - 64 | |
return $queryBuilder->orderBy('mix.votes', 'DESC'); | |
} | |
... lines 67 - 78 |
PhpStorm está un poco enfadado conmigo... pero eso es sólo porque está teniendo una mañana dura y necesita reiniciarse: nuestro código está, esperemos, bien.
Vuelve al método original, simplifica a$queryBuilder = $this->addOrderByVotesQueryBuilder()
y no le pases nada.
... lines 1 - 45 | |
public function findAllOrderedByVotes(string $genre = null): array | |
{ | |
$queryBuilder = $this->addOrderByVotesQueryBuilder(); | |
... lines 49 - 58 | |
} | |
... lines 60 - 78 |
¿No es bonito? Cuando actualizamos... ¡no está roto! ¡Toma ese PhpStorm!
A continuación, vamos a añadir una página de "espectáculo de mezclas" en la que podamos ver una única mezcla de vinilos. Por primera vez, consultaremos un único objeto de la base de datos y nos ocuparemos de lo que ocurre si no se encuentra ninguna mezcla que coincida.
Hey @Akili
It's better only to store info about the image in the database, for example their filename, render an img
tag pointing to the file, and let the browser request them.
Cheers!
I'm wondering how the setParameter method makes the subsequent SQL safer. Does it do something to parse the string to clean it up, or does it use native SQL features to pass the parameters (and hence let the database worry about wrapping the parameter)?
$queryBuilder->andWhere('mix.genre = :genre')
->setParameter('genre', $genre);
Hi,
Yeah, the doctrine will cover you with parameters so it is safe just pass it to ->setParameter()
. I can't describee how exactly it works internally, Doctrinie code is not so cool to explore
Cheers
How is it possible to match the aliases with a QueryBuilder provided by a third party?
`
private function addOrderByVotesQueryBuilder(QueryBuilder $queryBuilder = null): QueryBuilder
{
$queryBuilder = $queryBuilder ?? $this->createQueryBuilder('mix');
}
`
For example I'm trying to reuse a QueryBuilder with this design in EasyAdmins createIndexQueryBuilder(). But EasyAdmin uses a generic 'entity' as its alias. Because I am passing parent::createIndexQueryBuilder(...) to my QueryBuilder the aliases do not match up. Is there any solution?
Hey,
You can get an alias from QueryBuilder
object with ->getRootAliases()
it will return array of aliases and first element will be your alias!
Cheers!
I read about that method but wasn't sure wich key would be safe to use. If first key is always the entity key, then a huge "Thanks" for pointing that out. Appreciate the quick response!
As I know the first key is safe to use as entity key. I didn't heard about another behaviour.
Cheers!
Wouldn't a method like the following be more flexible?
private function addOrderByVotesQueryBuilder(QueryBuilder $queryBuilder = null, $column = null): QueryBuilder
{
$queryBuilder = $queryBuilder ?? $this->createQueryBuilder('mix');
$column = $column ?? 'mix.votes';
return $queryBuilder->orderBy($column, 'DESC');
}
By the way, whats a better coding style for the null coalescing operator .. ?? or you use ?:
Hey Ruflex,
It depends on your use cases. If you need an additional control - passing the column as an argument to the method like you're doing is a good idea. But if you need that flexibility - better make it simple as in our case :) And as always, the simplest your code - the easier to maintain it for other people, or even for you in the future ;)
About "??" or "?:" - that's not about coding style actually because it's a different implementation. Read about both of them in the PHP docs to understand the difference. In short, if the var might not exist - you should use ??, but if it always exists but might just be null sometimes - ?: syntax is enough.
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"babdev/pagerfanta-bundle": "^3.7", // v3.7.0
"doctrine/doctrine-bundle": "^2.7", // 2.7.0
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.12", // 2.12.3
"knplabs/knp-time-bundle": "^1.18", // v1.19.0
"pagerfanta/doctrine-orm-adapter": "^3.6", // v3.6.1
"pagerfanta/twig": "^3.6", // v3.6.1
"sensio/framework-extra-bundle": "^6.2", // v6.2.6
"stof/doctrine-extensions-bundle": "^1.7", // v1.7.0
"symfony/asset": "6.1.*", // v6.1.0
"symfony/console": "6.1.*", // v6.1.2
"symfony/dotenv": "6.1.*", // v6.1.0
"symfony/flex": "^2", // v2.2.2
"symfony/framework-bundle": "6.1.*", // v6.1.2
"symfony/http-client": "6.1.*", // v6.1.2
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/proxy-manager-bridge": "6.1.*", // v6.1.0
"symfony/runtime": "6.1.*", // v6.1.1
"symfony/twig-bundle": "6.1.*", // v6.1.1
"symfony/ux-turbo": "^2.0", // v2.3.0
"symfony/webpack-encore-bundle": "^1.13", // v1.15.1
"symfony/yaml": "6.1.*", // v6.1.2
"twig/extra-bundle": "^2.12|^3.0", // v3.4.0
"twig/twig": "^2.12|^3.0" // v3.4.1
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"symfony/debug-bundle": "6.1.*", // v6.1.0
"symfony/maker-bundle": "^1.41", // v1.44.0
"symfony/stopwatch": "6.1.*", // v6.1.0
"symfony/web-profiler-bundle": "6.1.*", // v6.1.2
"zenstruck/foundry": "^1.21" // v1.21.0
}
}
Hey SymfonyCast!
I had a question about querying images from the database. Would it be better to make URL requests for images statically from api's or is it better to query images from the database? And yes, I've learned that Doctrine queries the database in the background; with the focus on instantiating objects not tables.