Aller au contenu principal

DQL et le Query Builder

Avec Doctrine, on dispose d'entités décrivant la structure d'une table, et de repositories nous offrant des méthodes pour communiquer avec la base de données à propos d'une entité.

Les méthodes du Repository​

Avec un repository de base, on pourra appeler des méthodes offertes par la classe EntityRepository de Doctrine :

  • findAll pour récupérer tous les enregistrements d'une table
  • find pour récupérer un enregistrement à partir de son ID
  • findBy pour récupérer une collection en fonction de certains critères
  • etc...

L'avantage est donc de pouvoir type-hinter un repository dans un contrôleur ou un service par exemple, puis de pouvoir récupérer facilement un enregistrement ou une collection d'enregistrements.

Par exemple, sur une collection de produits dont on voudrait uniquement les produits visibles et en promotion, on pourrait, depuis un contrôleur, utiliser la méthode findBy :

$products = $productRepository->findBy(['visible' => true, 'discount' => true]);

Mais on pourrait avoir des besoins plus complexes : dans un contrôleur de page d'accueil par exemple, comment ferais-je si je voulais tous les produits visibles, en promotion, limités à 5, ordonnés par prix croissant et créés il y a moins d'un an par exemple ?

Le query builder peut nous permettre, comme son nom l'indique, de créer des requêtes plus complexes.

DQL : Doctrine Query Language​

Avec Doctrine, on peut utiliser le langage DQL (Doctrine Query Language) pour construire des requêtes.

Depuis une instance d'EntityManagerInterface, il est possible d'appeler la méthode createQuery à laquelle on passera une syntaxe DQL.

Reprenons l'exemple ci-dessus : je veux tous les produits visibles, en promotion, limités à 5, ordonnés par prix croissant et créés il y a moins d'un an.

Depuis une méthode d'un ProductRepository, je peux récupérer l'entity manager et créer la requête :

$products = $this->getEntityManager()->createQuery("SELECT p FROM " . Product::class . " p WHERE p.visible = true AND p.discount = true AND p.date_created > DATE_SUB(CURRENT_DATE(), 1, 'YEAR') ORDER BY p.priceVatFree")
->setMaxResults(5)
->getResult();

On remarque directement plusieurs choses :

  • Le DQL fonctionne toujours avec des entités et non des tables. Ce sont bien les classes de notre code dont il est question ici
  • Si on applique des clauses WHERE, on va de nouveau se référer à l'entité concernée. On a donc aliasé Product en p ici
  • Le DQL nous offre quelques fonctions qui peuvent être utiles : pour effectuer une opération sur une date, on pourra utiliser DATE_ADD et DATE_SUB par exemple, qui se rapprochent beaucoup des fonctions DATE_ADD et DATE_SUB de MySQL
  • On aura également la fonction CURRENT_DATE() pour la date du jour
  • Si on veut appliquer une limite, alors le mot-clé LIMIT SQL est à remplacer par un appel à une méthode setMaxResults
  • Pour récupérer les résultats, on emploie la méthode getResult() sur la requête générée par createQuery

Le DQL offre de nombreuses possibilités que vous pouvez retrouver dans sa documentation.

Le Query Builder​

Il est possible d'utiliser une autre syntaxe, complètement objet, pour générer une requête. On peut utiliser le QueryBuilder depuis n'importe quel repository, et son rôle sera de nous générer une requête sans avoir à l'écrire directement en DQL.

Reprenons l'exemple des produits :

$products = $this->createQueryBuilder('p')
->andWhere('p.discount = :discount')
->andWhere('p.visible = :visible')
->andWhere('p.date_created > :last_year')
->setParameter('discount', true)
->setParameter('visible', true)
->setParameter('last_year', (new DateTime())->modify('-1 year'))
->orderBy('p.priceVatFree', 'ASC')
->setMaxResults(5)
->getQuery()
->getResult();
getResult()

La méthode getResult() a un type de retour fixé à mixed.

On peut ajouter une annotation PHPDoc @return pour indiquer qu'on retourne un tableau de Product. Dans un repository :

/**
* @return Product[]
*/
public function getHomepageProducts(): array
{
return $this->createQueryBuilder('p')
->andWhere('p.discount = :discount')
->andWhere('p.visible = :visible')
->andWhere('p.date_created > :last_year')
->setParameter('discount', true)
->setParameter('visible', true)
->setParameter('last_year', (new DateTime())->modify('-1 year'))
->orderBy('p.priceVatFree', 'ASC')
->setMaxResults(5)
->getQuery()
->getResult();
}

Jointures​

Parfois, il peut être utile de sélectionner des données provenant de plusieurs tables à la fois.

Par exemple, si je voulais afficher une liste d'articles, et, pour chacun d'eux, afficher le nom de leur catégorie, alors je pourrais retoucher le template d'affichage d'un article :

templates/article/_article_card.html.twig
  <!-- ... -->
<a href="#"><h5 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">{{ article.title }}</h5></a>
+ <h6>{{ article.category.name }}</h6>
<!-- ... -->

Dans mon contrôleur, vu que j'affiche tous les articles, j'injecte la classe ArticleRepository et utilise la méthode findAll() :

$articles = $articleRepository->findAll();

Au moment d'afficher la page, Doctrine va alors aller requêter chaque catégorie au moment où j'en ai besoin, pour pouvoir afficher son nom.

Sur un ensemble de 150 articles et 15 catégories, on se retrouve avec un nombre conséquent de requêtes. Cependant, Doctrine prend soin d'optimiser : si une catégorie a déjà été requêtée, il va la réutiliser pour éviter de requêter de nouveau. Mais on pourrait éviter ces requêtes additionnelles en effectuant directement une jointure, au moment de requêter les articles. Ceci me permettrait de préparer les données directement, avec une requête.

Pour effectuer une jointure avec Doctrine, on va encore une fois penser "objet", et non "table" de base de données.

Créons une méthode dans ArticleRepository, nomméee findAllArticlesWithCategory() :

src/Repository/ArticleRepository.php
/**
* @return Article[]
*/
public function findAllArticlesWithCategory(): array
{
return $this->createQueryBuilder('a')
->join('a.category', 'c')
->addSelect('c')
->getQuery()
->getResult();
}

Pour joindre la catégorie, nous avons utilisé la méthode join du QueryBuilder. En paramètre, on réutilise l'alias de l'entité Article, a, pour accéder à ses propriétés : ici category. On donne ensuite en second paramètre un alias à l'entité Category, c.

On utilise ensuite une méthode addSelect : cette méthode permet d'aller chercher les informations des catégories et de les ajouter à la réponse renvoyée par MySQL. Par défaut, quand on utilise createQueryBuilder, la seule entité intégrée à la commande SELECT est Article. Nous indiquons donc à Doctrine que nous souhaitons ajouter la catégorie.

Dans le contrôleur, on peut alors utiliser la méthode findAllArticlesWithCategory du Repository :

- $articles = $articleRepository->findAll();
+ $articles = $articleRepository->findAllArticlesWithCategory();

Si on actualise la page, alors le nombre de requêtes a chuté : les informations de catégories ont été incluses aux résultats retournés par MySQL, Doctrine peut donc les trouver directement, sans avoir à effectuer de requêtes supplémentaires.

Optimisation

Une optimisation de ce type peut être utile lorsque des ralentissements ou problèmes de performance sont constatés dans l'application. La réduction du nombre de requêtes permet d'accélérer le temps de traitement pour l'affichage de la page.

Cependant, il n'est pas toujours nécessaire d'optimiser directement. On risque alors de faire de la "pré-optimisation", ce qui n'est pas utile et peut faire perdre du temps lors des développements.