Symfony 2 Joboard : Подробнее о моделях

Объект Doctrine Query

Из требований второй части у нас должно быть: “На домашней странице пользователь должен видеть последние активные вакансии”. На данный момент отображаются все вакансии вне зависимости от того активные они или нет.

<?php
# src/App/JoboardBundle/Controller/JobController.php
class JobController extends Controller
{
    // ...
    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();
        $entities = $em->getRepository('AppJoboardBundle:Job')->findAll();
        return $this->render('AppJoboardBundle:Job:index.html.twig', array(
            'entities' => $entities
        ));
    }
}

Активная вакансия — это та, которая была опубликована менее, чем 30 дней назад. Метод $entities = $em->getRepository(‘AppJoboardBundle’)->findAll() выполняет запрос к базе данных и получает все вакансии. Мы не уточняем условия выборки и поэтому получаем все записи из базы данных.

Давайте укажем, чтобы были выбраны только активные вакансии:

<?php
# src/App/JoboardBundle/Controller/JobController.php
public function indexAction()
{
    $em = $this->getDoctrine()->getManager();
    $query = $em->createQuery(
        'SELECT j FROM AppJoboardBundle: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('AppJoboardBundle:Job:index.html.twig', array(
        'entities' => $entities
    ));
}

Метод $em->createQuery очень похож на обычный SQL запрос, на самом деле это обёртка над обычным запросом, которой выполняется с помощью PDO. Вместо названия таблицы используется модель Job бандла AppJoboardBundle (AppJoboardBundle:Job), которая транслируется в название таблицы в базе данных. В запросе присутствуют строчка-параметр :date, он означает, что в это место будет вставлена дата, которая передаётся в методе setParameter. Можно было просто вставить дату в строку запроса в методе createQuery, но лучше использовать параметры, потому что Doctrine автоматически их экранирует и конвертирует в нужный формат полей базы данных (таким образом мы защищаем приложение от sql injection). $entities = $query->getResult() — возвращает коллекцию найденных моделей вакансий.

Отладка SQL запросов сгенерированных Doctrine

Иногда очень полезно взглянуть на автоматически сгенерированный запрос, например, если надо понять почему он не работает как положено. Благодаря Symfony Web Debug Toolbar в среде разработки dev вся информация о запросе доступна через браузер найдите внизу панель дебаггинга и нажмите значок, показанный на изображении:

Symfony2 Debug Panel

Сериализация объекта

Даже если этот код работает, он далек от совершенства и не принимает во внимание все требования второго дня: “Пользователь может вернуться и активировать или продлить действие вакансии на 30 дней ”

Но так как вышеуказанный код учитывает только поле created_at, а оно указывает на дату создания вакансии, то мы не можем выполнить это требование. Если вы помните структуру базы данных описанную в третьей части, то мы также использовали поле expires_at. На данный момент, если значение этого поля не указано в файле фикстур, то оно остаётся пустым. Но мы можем автоматически задавать ему значение на 30 дней больше от текущей даты.

Если вы хотите автоматически выполнить какие-либо преобразования над объектом перед его сериализацией, то следует добавить параметр lifecycleCallbacks в файл описания сущности. Так же как мы сделали ранее для поля created_at:

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

prePersist — это события, которые будут вызываться перед тем как модель будет сохранена в базу, в случае если это новая запись. Т.е. перед сохранением будут вызваны функции модели setCreatedAtValue, setExpiresAtValue. preUpdate — это событие, которое тоже вызывается перед сохранением модели в базу, но только если запись уже была добавлена. Т.е перед обновлением вызывается метод модели setUpdatedAtValue.

Теперь следует пересоздать классы сущностей чтобы Doctrine добавила новые функции, для этого выполните команду в консоли:

php app/console doctrine:generate:entities AppJoboardBundle

Откройте файл src/App/JoboardBundle/Entity/Job.php и отредактируйте вновь созданные методы

<?php
# src/App/JoboardBundle/Entity/Job.php 
// ...
class Job
{
    // ...
    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));
        }
    }
}

Теперь изменим выборку сущностей в соответствии с требованиями задания. Будем использовать поле expires_at вместо created_at.

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

Подробнее о фикстурах

При обновлении главной страницы Joboard ничего не изменилось, так как все вакансии были созданы всего несколько дней назад (если вакансий нет, значит вы их не добавили или очень давно читали предыдущие статьи, обновите фиксуры — php app/console doctrine:fixtures:load, должны появиться две вакансии). Давайте изменим фикстуры, чтобы добавить вакансию с истекшим сроком действия.

<?php
# src/App/JoboardBundle/DataFixtures/ORM/LoadJobData.php
// ...
    public function load(ObjectManager $em)
    {
        $jobExpired = new Job();
        $jobExpired->setCategory($em->merge($this->getReference('category-programming')));
        $jobExpired->setType('full-time');
        $jobExpired->setCompany('DevAcademy');
        $jobExpired->setLogo('logo.gif');
        $jobExpired->setUrl('http://www.devacademy.ru/');
        $jobExpired->setPosition('Web Developer Expired');
        $jobExpired->setLocation('Moscow, Rissia');
        $jobExpired->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.');
        $jobExpired->setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit');
        $jobExpired->setIsPublic(true);
        $jobExpired->setIsActivated(true);
        $jobExpired->setToken('job_expired');
        $jobExpired->setEmail('[email protected]');
        $jobExpired->setCreatedAt(new \DateTime('2005-12-01'));
        // ...
        $em->persist($jobExpired);
        // ...
    }
// …

Перезагрузите фикстуры и обновите страницу в вашем браузере, вакансии с истекшим сроком действия должны пропасть.

php app/console doctrine:fixtures:load

Рефакторинг

Хотя всё работает как надо, осталось ещё несколько нюансов. Заметили?

Код запроса принадлежит слою контроллера, а не модели. В модели MVC вся бизнес логика должна быть в модели, а контроллер лишь вызывает методы модели для получения данных. Так как код возвращает коллекцию вакансий, то его следует разместить в модели. Для этого нам потребуется создать класс репозитория для сущности Job и добавить в него наш запрос.

Откройте файл /src/App/JoboardBundle/Resources/config/doctrine/Job.orm.yml и добавьте в него после параметра type: entity новый параметр — путь до класса репозитория repositoryClass: App\JoboardBundle\Repository\JobRepository, в итоге должно получиться вот так:

App\JoboardBundle\Entity\Job:
    type: entity
    repositoryClass: App\JoboardBundle\Repository\JobRepository
    # …

Doctrine генерирует класс репозитория автоматически при использовании команды generate:entities. Выполните команду в консоли:

php app/console doctrine:generate:entities AppJoboardBundle

Что такое класс репозитория? Это самый обычный класс, который является вспомогательным для модели. В нём хранятся все дополнительные методы для работы с моделью.

Теперь добавьте новый метод getActiveJobs() в только что созданный класс репозитория. Этот метод возвращает коллекцию вакансий сортируя их по значению expires_at (а также он фильтрует их по категориям, если параметр $categoryId был передан методу)

<?php
# src/App/JoboardBundle/Repository/JobRepository.php
namespace App\JoboardBundle\Repository;
use Doctrine\ORM\EntityRepository;
/**
* JobRepository
*
* This class was generated by the Doctrine ORM. Add your own custom
* repository methods below.
*/
class JobRepository extends EntityRepository
{
    public function getActiveJobs($categoryId = 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($categoryId)
        {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $categoryId);
        }
        $query = $qb->getQuery();
        return $query->getResult();
    }
}

$qb = $this->createQueryBuilder(‘j’) — это строитель SQL запросов в Doctrine. j — это алиас для текущей модели из которой выполняется этот метод, т.е. в данному случае этой моделью является Job. Далее идёт объектно-ориентированный синтаксис для создания запроса. Чтобы получить результат нужно из QueryBuilder получить текущий построенный запрос и делается это вызовом метода $query = $qb->getQuery(), после этого можно получить список вакансий с помощью $query->getResult().

Теперь в контроллере мы можем использовать этот метод

<?php
// ...
    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();
        $entities = $em->getRepository('AppJoboardBundle:Job')->getActiveJobs();
        return $this->render('AppJoboardBundle:Job:index.html.twig', array(
            'entities' => $entities
        ));
    }
// ...

Преимущества рефакторинга:

  1. Логика выборки вакансий находится в модели.
  2. Код в контроллере стал гораздо проще
  3. Метод getActiveJobs() можно использовать в других методах контроллера
  4. Код модели можно подвергнуть юнит тестированию.

Категории на домашней странице

По требованиям второго дня все вакансии должны быть отсортированы по категориям. До настоящего момента мы вообще не уделяли внимания категориям. Для начала нам надо получить список всех категорий хотя бы с одной активной вакансией.

Создайте класс репозитория для сущности Category как мы делали ранее:

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

Сгенерируйте файл класса, выполните команду в консоли:

php app/console doctrine:generate:entities AppJoboardBundle

Откройте этот файл и добавьте метод getWithJobs()

<?php
namespace App\JoboardBundle\Repository;
use Doctrine\ORM\EntityRepository;
/**
* CategoryRepository
*
* This class was generated by the Doctrine ORM. Add your own custom
* repository methods below.
*/
class CategoryRepository extends EntityRepository
{
    public function getWithJobs()
    {
        $query = $this->getEntityManager()->createQuery(
            'SELECT c FROM AppJoboardBundle: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();
    }
}

Измените метод indexAction:

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();
        $categories = $em->getRepository('AppJoboardBundle:Category')->getWithJobs();
        foreach($categories as $category) {
            $category->setActiveJobs($em->getRepository('AppJoboardBundle:Job')->getActiveJobs($category->getId()));
        }
        return $this->render('AppJoboardBundle:Job:index.html.twig', array(
            'categories' => $categories
        ));
    }

Для этого необходимо добавить новое свойство activeJobs в класс модели Category:

<?php
# src/App/JoboardBundle/Entity/Category.php
class Category
{
    // ...
    private $activeJobs;
    // ...
    public function setActiveJobs($jobs)
    {
        $this->activeJobs = $jobs;
    }
    public function getActiveJobs()
    {
        return $this->activeJobs;
    }
}

В шаблоне src/App/JoboardBundle/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('app_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 %}

Ограничение результатов

Осталось выполнить всего одно требование. Мы должны отображать не больше десяти вакансий. Это довольно легко выполнить, добавив параметр $max в метод JobRepository::getActiveJobs().

<?php
# src/App/JoboardBundle/Repository/JobRepository.php
    public function getActiveJobs($categoryId = 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($categoryId) {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $categoryId);
        }
        $query = $qb->getQuery();
        return $query->getResult();
    }

Измените вызов метода indexAction в соответствии с новой сигнатурой.

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();
        $categories = $em->getRepository('AppJoboardBundle:Category')->getWithJobs();
        foreach($categories as $category)
        {
            $category->setActiveJobs($em->getRepository('AppJoboardBundle:Job')->getActiveJobs($category->getId(), 10));
        }
        return $this->render('AppJoboardBundle:Job:index.html.twig', array(
            'categories' => $categories
        ));
    }
// …

Тонкая настройка

В методе JobController::indexAction мы прописали максимальное число вакансий, которое хотим получать. Но логичнее сделать это ограничение конфигурируемым. Symfony позволяет добавлять свои параметры в файле app/config/parameters.yml указав в parameters нужное значение.

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

Теперь мы можем получить значение этого параметра в контроллере

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();
        $categories = $em->getRepository('AppJoboardBundle:Category')->getWithJobs();
        foreach($categories as $category) {
            $category->setActiveJobs($em->getRepository('AppJoboardBundle:Job')->getActiveJobs(
                $category->getId(),
                $this->container->getParameter('max_jobs_on_homepage'))
            );
        }
        return $this->render('AppJoboardBundle:Job:index.html.twig', array(
            'categories' => $categories
        ));
    }
// ...

Динамические фикстуры

Пока что вы не заметите каких-либо отличий, так как у нас очень мало вакансий в базе. Нам следует добавить их больше в фикстуры. Вы можете просто скопировать старые вакансии несколько десятков раз вручную, но существует метод лучше. Повторение это плохо, даже в файлах фикстур.

<?php
# src/App/JoboardBundle/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('[email protected]');
        $em->persist($job);
    }
    // ...
    $em->flush();
}
// ...

Теперь можно перезагрузить фикстуры командой php app/console doctrine:fixtures:load и в результате мы получим только 10 вакансий в категории “Программирование” на нашей домашней странице.

Ограничиваем доступ к странице с вакансией

Если срок вакансии истек, то доступ к ней должен быть закрыт даже если вам известен её URL адрес. Выполните в БД запрос SELECT id, token FROM job WHERE expires_at < NOW(), в ответе вы получите idзаписи с истёкшим сроком действия (в моём случае id — 9), теперь вставьте этот id в URL, например:

http://joboard.local/app_dev.php/job/ооо-компания/москва/9/web-разработчик/

Вместо выдачи вакансии мы должны перенаправить пользователя на страницу с ошибкой 404. Для этого создадим новый метод в классе репозитория JobRepository:

<?php
# src/App/JoboardBundle/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;
    }

Метод getSingleResult() генерирует исключение типа Doctrine\ORM\NoResultException при отсутствии результатов запроса и Doctrine\ORM\NonUniqueResultException при возврате более одного результата. Если вы будете использовать этот метод, то убедитесь, что он возвращает только один результат, а так же его следует использовать в блоке try-catch.

Теперь измените метод showAction() в контроллере JobController как показано:

<?php
# src/App/JoboardBundle/Controller/JobController.php
// ...
$entity = $em->getRepository('AppJoboardBundle:Job')->getActiveJob($id);
// ...

Теперь, если вы попробуете просмотреть вакансию с истекшим сроком действия, то вы будете перенаправлены на страницу 404.

Это всё на сегодня. Встретимся в следующей части и обсудим страницу с категориями.