Jobeet FR

Le tutoriel pour Symfony2 en français

Pleine page

L'objet Query de Doctrine

Scénario du deuxième chapitre: "Sur la page d'accueil, l'utilisateur voit les dernières offres actives." Mais actuellement, toutes les offres sont affichées, qu'elles soient actives ou non:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...
 
class JobController extends Controller
{
  public function indexAction()
  {
    $em = $this->getDoctrine()->getEntityManager();
 
    $entities = $em->getRepository('EnsJobeetBundle:Job')->findAll();
 
    return $this->render('EnsJobeetBundle:Job:index.html.twig', array(
      'entities' => $entities
    ));
 
  // ...
}

Une offre active est une offre de moins de 30 jours. La méthode $entities = $em->getRepository('EnsJobeetBundle:Job')->findAll(); va créer une requête à la BDD pour obtenir toutes les offres. Nous ne spécifions aucune condition, ce qui signifie que tous les enregistrements sont extraits de la BDD.

Changeons cela pour sélectionner uniquement les offres actives:

public function indexAction()
{
  $em = $this->getDoctrine()->getEntityManager();
 
  $query = $em->createQuery(
    'SELECT j FROM EnsJobeetBundle:Job j WHERE j.created_at > :date'
  )->setParameter('date', date('Y-m-d H:i:s', time() - 86400 * 30));
  $entities = $query->getResult();
 
  return $this->render('EnsJobeetBundle:Job:index.html.twig', array(
    'entities' => $entities
  ));
}

Débuguer les requêtes générées par Doctrine

Parfois, il est utile de voir les requêtes générées par Doctrine, par exemple, pour débuguer une requête qui ne fonctionne pas comme prévu. Dans l'environnement de dev, grâce à la Web Debug Toolbar de Symfony, toutes les informations dont vous avez besoin sont disponibles dans votre navigateur http://jobeet.local/app_dev.php):


Sérialisation d'objets

Même si le code ci-dessus fonctionne, il est loin d'être parfait, car il ne tient pas compte de certains scénarios du deuxième chapitre: "Un utilisateur peut revenir réactiver ou prolonger la validité de l'offre pour une période de 30 jours supplémentaires..."

Mais comme le code ci-dessus ne repose que sur la valeur created_at, et parce que cette colonne stocke la date de création, on ne peut pas satisfaire ce scénario.

Si vous vous souvenez du schéma de BDD que nous avons décrit dans le troisième chapitre, nous avons aussi défini une colonne expires_at. À l'heure actuelle, si cette valeur n'est pas définie dans le fichier d'installation, il reste toujours vide. Mais quand une offre est créée, cette valeur peut être automatiquement réglée à 30 jours après la date du jour.

Lorsque vous avez besoin de faire quelque chose automatiquement avant qu'un objet Doctrine ne soit sérialisé dans la BDD, vous pouvez ajouter une nouvelle entrée lifecycleCallbacks dans le fichier qui mappe les objets de la BDD, comme nous l'avons fait précédemment pour la colonne created_at:

# src/Ens/JobeetBundle/Resources/config/doctrine/Job.orm.yml
# ...
 
  lifecycleCallbacks:
    prePersist: [ setCreatedAtValue, setExpiresAtValue ]
    preUpdate: [ setUpdatedAtValue ]

Maintenant, nous devons reconstruire les classes d'entités afin que Doctrine ajoute la nouvelle fonction:

php app/console doctrine:generate:entities EnsJobeetBundle

Ouvrez le fichier src/Ens/JobeetBundle/Entity/Job.php et modifiez la nouvelle fonction:

# src/Ens/JobeetBundle/Entity/Job.php
# ...
 
public function setExpiresAtValue()
{
  if(!$this->getExpiresAt())
  {
    $now = $this->getCreatedAt() ? $this->getCreatedAt()->format('U') : time();
    $this->expires_at = new \DateTime(date('Y-m-d H:i:s', $now + 86400 * 30));
  }
}

Maintenant, nous allons modifier l'action pour utiliser la colonne expires_at au lieu de created_at pour sélectionner les offres actives:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...
 
public function indexAction()
{
  $em = $this->getDoctrine()->getEntityManager();
 
  $query = $em->createQuery(
    'SELECT j FROM EnsJobeetBundle:Job j WHERE j.expires_at > :date'
  )->setParameter('date', date('Y-m-d H:i:s', time()));
  $entities = $query->getResult();
 
  return $this->render('EnsJobeetBundle:Job:index.html.twig', array(
    'entities' => $entities
  ));
}

Aller plus loin avec les fixtures

L'actualisation de la page d'accueil Jobeet dans votre navigateur ne changera rien car les offres dans la BDD ont été affichées il y a seulement quelques jours. Nous allons changer les fixtures pour ajouter une offre qui est déjà expirée:

// src/Ens/JobeetBundle/DataFixtures/ORM/LoadJobData.php
// ...
 
$job_expired = new Job();
$job_expired->setCategory($em->merge($this->getReference('category-programming')));
$job_expired->setType('full-time');
$job_expired->setCompany('Sensio Labs');
$job_expired->setLogo('sensio-labs.gif');
$job_expired->setUrl('http://www.sensiolabs.com/');
$job_expired->setPosition('Web Developer Expired');
$job_expired->setLocation('Paris, France');
$job_expired->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.');
$job_expired->setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit');
$job_expired->setIsPublic(true);
$job_expired->setIsActivated(true);
$job_expired->setToken('job_expired');
$job_expired->setEmail('job@example.com');
$job_expired->setCreatedAt(new \DateTime('2005-12-01'));
 
// ...
 
$em->persist($job_expired);

Rechargez les fixtures et rafraîchissez votre navigateur pour vous assurer que l'ancienne offre ne s'affiche pas:

php app/console doctrine:fixtures:load

Refactorisation

Bien que le code que nous avons écrit fonctionne très bien, ce n'est pas encore parfait. Voyez-vous le problème?

La requête Doctrine n'appartient pas à l'action (la couche Contrôleur), il appartient à la couche Modèle. Dans le modèle MVC, le Modèle définit toute la logique métier, et le Contrôleur appelle le Modèle pour récupérer les données qu'il contient. Comme le code renvoie une collection d'offres, déplaçons le code vers le Modèle. Pour cela, nous devons créer une classe de dépôt personnalisée pour l'entité Job et ajouter la requête à cette classe.

Ouvrez /src/Ens/JobeetBundle/Resources/config/doctrine/Job.orm.yml et ajoutez ce qui suit:

# /src/Ens/JobeetBundle/Resources/config/doctrine/Job.orm.yml
 
Ens\JobeetBundle\Entity\Job:
  type: entity
  repositoryClass: Ens\JobeetBundle\Repository\JobRepository
  # ...

Doctrine peut générer la classe de dépôt pour vous en exécutant la commande generate:entities utilisée précédemment:

php app/console doctrine:generate:entities EnsJobeetBundle

Ensuite, ajoutez une nouvelle méthode - getActiveJobs() - à la classe de dépôt nouvellement générée. Cette méthode va récupérer toutes les entités Job actives triées par la colonne expires_at (et filtrées par catégorie si elle reçoit le paramètre $category_id).

// src/Ens/JobeetBundle/Repository/JobRepository.php
 
namespace Ens\JobeetBundle\Repository;
use Doctrine\ORM\EntityRepository;
 
class JobRepository extends EntityRepository
{
  public function getActiveJobs($category_id = null)
  {
    $qb = $this->createQueryBuilder('j')
      ->where('j.expires_at > :date')
      ->setParameter('date', date('Y-m-d H:i:s', time()))
      ->orderBy('j.expires_at', 'DESC');
 
    if($category_id)
    {
      $qb->andWhere('j.category = :category_id')
         ->setParameter('category_id', $category_id);
    }
 
    $query = $qb->getQuery();
 
    return $query->getResult();
  }
}

Maintenant, le code de l'action peut utiliser cette nouvelle méthode pour récupérer les offres actives.

// src/Ens/JobeetBundle/Controller/JobController.php
// ...
 
public function indexAction()
{
  $em = $this->getDoctrine()->getEntityManager();
 
  $entities = $em->getRepository('EnsJobeetBundle:Job')->getActiveJobs();
 
  return $this->render('EnsJobeetBundle:Job:index.html.twig', array(
    'entities' => $entities
  ));
}
 
// ...

Cette refactorisation a plusieurs avantages par rapport au code précédent:

  • - La logique pour obtenir les offres actives est maintenant dans le Modèle, où elle appartient
  • - Le code du Contrôleur est plus mince et beaucoup plus lisible
  • - La méthode getActiveJobs() est ré-utilisable (par exemple dans une autre action)
  • - Le code du modèle est désormais testable unitairement

Les catégories sur la page d'accueil

Conformément aux scénarios du deuxième chapitre, nous avons besoin d'avoir des offres classées par catégories. Jusqu'à présent, nous n'avons pas pris la notion de catégorie d'offre en compte. Selon les scénarios, la page d'accueil doit afficher les offres par catégorie. Tout d'abord, nous avons besoin de toutes les catégories avec au moins une offre.

Créez une classe de dépôt pour l'entité Category comme nous l'avons fait pour Job:

# /src/Ens/JobeetBundle/Resources/config/doctrine/Category.orm.yml
 
Ens\JobeetBundle\Entity\Category:
  type: entity
  repositoryClass: Ens\JobeetBundle\Repository\CategoryRepository
  #...

Générez la classe de dépôt:

php app/console doctrine:generate:entities EnsJobeetBundle

Ouvrez la classe CategoryRepository et ajoutez une méthode getWithJobs():

// src/Ens/JobeetBundle/Repository/CategoryRepository.php
 
namespace Ens\JobeetBundle\Repository;
use Doctrine\ORM\EntityRepository;
 
class CategoryRepository extends EntityRepository
{
  public function getWithJobs()
  {
    $query = $this->getEntityManager()->createQuery(
      'SELECT c FROM EnsJobeetBundle:Category c LEFT JOIN c.jobs j WHERE j.expires_at > :date'
    )->setParameter('date', date('Y-m-d H:i:s', time()));
 
    return $query->getResult();
  }
}

Modifiez l'action index en conséquence:

public function indexAction()
{
  $em = $this->getDoctrine()->getEntityManager();
 
  $categories = $em->getRepository('EnsJobeetBundle:Category')->getWithJobs();
 
  foreach($categories as $category)
  {
    $category->setActiveJobs($em->getRepository('EnsJobeetBundle:Job')->getActiveJobs($category->getId()));
  }
 
  return $this->render('EnsJobeetBundle:Job:index.html.twig', array(
    'categories' => $categories
  ));
}

Pour que cela fonctionne, nous devons ajouter une nouvelle propriété à notre classe Category, les active_jobs:

// src/Ens/JobeetBundle/Entity/Category.php
 
namespace Ens\JobeetBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
 
class Category
{
  // ...
 
  private $active_jobs;
 
  // ...
 
  public function setActiveJobs($jobs)
  {
    $this->active_jobs = $jobs;
  }
 
  public function getActiveJobs()
  {
    return $this->active_jobs;
  }
}

Dans le template, nous avons besoin de parcourir toutes les catégories et afficher les offres actives:

<!-- src/Ens/JobeetBundle/Resources/views/Job/index.html.twig -->
<!-- ... -->
 
{% block content %}
  <div id="jobs">
    {% for category in categories %}
      <div>
        <div class="category">
          <div class="feed">
            <a href="">Feed</a>
          </div>
          <h1>{{ category.name }}</h1>
        </div>
        <table class="jobs">
          {% for entity in category.activejobs %}
            <tr class="{{ cycle(['even', 'odd'], loop.index) }}">
              <td class="location">{{ entity.location }}</td>
              <td class="position">
                <a href="{{ path('ens_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}">
                  {{ entity.position }}
                </a>
              </td>
              <td class="company">{{ entity.company }}</td>
            </tr>
          {% endfor %}
        </table>
      </div>
    {% endfor %}
  </div>
{% endblock %}

Limiter les résultats

Il reste encore une condition à implémenter pour la liste des offres dans la page d'accueil: il faut limiter la liste des offres à 10 articles. Il est assez simple d'ajouter le paramètre $max à la méthode JobRepository::getActiveJobs()

public function getActiveJobs($category_id = null, $max = null)
{
  $qb = $this->createQueryBuilder('j')
    ->where('j.expires_at > :date')
    ->setParameter('date', date('Y-m-d H:i:s', time()))
    ->orderBy('j.expires_at', 'DESC');
 
  if($max)
  {
    $qb->setMaxResults($max);
  }
 
  if($category_id)
  {
    $qb->andWhere('j.category = :category_id')
       ->setParameter('category_id', $category_id);
  }
 
  $query = $qb->getQuery();
 
  return $query->getResult();
}

Changez l'appel à getActiveJobs afin d'inclure le paramètre $max:

// src/Ens/JobeetBundle/Controller/JobController.php
 
$category->setActiveJobs($em->getRepository('EnsJobeetBundle:Job')->getActiveJobs($category->getId(), 10));

Configuration personnalisée

Dans la méthode indexAction de JobController, nous avons fixé le nombre d'offres maximum retournées pour une catégorie. Il aurait été préférable de laisser la limite de 10 configurable. Dans Symfony, vous pouvez définir des paramètres personnalisés pour votre application dans le fichier app/config/config.yml, sous l'entrée parameters:

# app/config/config.yml
# ...
 
parameters:
    max_jobs_on_homepage: 10

Cela est maintenant accessible dans un contrôleur:

// src/Ens/JobeetBundle/Controller/JobController
// ...
 
public function indexAction()
{
  $em = $this->getDoctrine()->getEntityManager();
 
  $categories = $em->getRepository('EnsJobeetBundle:Category')->getWithJobs();
 
  foreach($categories as $category)
  {
    $category->setActiveJobs($em->getRepository('EnsJobeetBundle:Job')->getActiveJobs($category->getId(), $this->container->getParameter('max_jobs_on_homepage')));
  }
 
  return $this->render('EnsJobeetBundle:Job:index.html.twig', array(
    'categories' => $categories
  ));
}

Fixtures dynamiques

Pour l'instant, vous ne verrez aucune différence parce que nous avons une très petite quantité d'offres dans notre BDD. Nous avons besoin d'ajouter un tas d'offres à la fixture. Ainsi, vous pouvez copier et coller des offres existantes, dix ou vingt fois à la main... mais il y a une meilleure façon. La duplication est une mauvais solution, même pour les fichiers fixture:

// src/Ens/JobeetBundle/DataFixtures/ORM/LoadJobData.php
// ...
 
public function load(ObjectManager $em)
{
  // ...
 
  for($i = 100; $i <= 130; $i++)
  {
    $job = new Job();
    $job->setCategory($em->merge($this->getReference('category-programming')));
    $job->setType('full-time');
    $job->setCompany('Company '.$i);
    $job->setPosition('Web Developer');
    $job->setLocation('Paris, France');
    $job->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.');
    $job->setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit');
    $job->setIsPublic(true);
    $job->setIsActivated(true);
    $job->setToken('job_'.$i);
    $job->setEmail('job@example.com');
 
    $em->persist($job);
  }
 
  $em->flush();
}
 
// ...

Vous pouvez maintenant recharger les fixtures avec la commande doctrine:fixtures:load et vérifiez que seulement 10 offres sont affichées sur la page d'accueil pour la catégorie Programming:


Sécuriser la page d'offres

Quand une offre arrive à expiration, même si vous connaissez l'URL, il ne doit pas être possible d'y accéder. Essayez l'URL d'un emploi expiré (remplacez l'ID avec l'id réel dans votre BDD - SELECT id, token FROM jobeet_job WHERE expires_at < NOW())

/app_dev.php/job/sensio-labs/paris-france/ID/web-developer-expired

Au lieu d'afficher l'offre, nous devons rediriger l'utilisateur vers une page d'erreur 404. Pour cela, nous allons créer une nouvelle fonction dans JobRepository:

// src/Ens/JobeetBundle/Repository/JobRepository.php
// ...
 
public function getActiveJob($id)
{
  $query = $this->createQueryBuilder('j')
    ->where('j.id = :id')
    ->setParameter('id', $id)
    ->andWhere('j.expires_at > :date')
    ->setParameter('date', date('Y-m-d H:i:s', time()))
    ->setMaxResults(1)
    ->getQuery();
 
  try {
    $job = $query->getSingleResult();
  } catch (\Doctrine\Orm\NoResultException $e) {
    $job = null;
  }
 
  return $job;
}

La méthode getSingleResult() renvoie une exception Doctrine\ORM\NoResultException si aucun résultat n'est retourné et une exception Doctrine\ORM\NonUniqueResultException si plus d'un résultat est retourné. Si vous utilisez cette méthode, vous pouvez avoir besoin de l'envelopper dans un bloc try-catch et de s'assurer qu'un seul résultat est retourné.

Maintenant, modifiez showAction de JobController afin d'utiliser la méthode du nouveau dépôt:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...
 
$entity = $em->getRepository('EnsJobeetBundle:Job')->getActiveJob($id);
 
// ...

Maintenant, si vous essayez d'obtenir une offre expirée, vous serez redirigé vers une page 404.


Chapitre précédent Chapitre suivant


Une question ? Une réaction ?

comments powered by Disqus