English spoken conference
Caution: You are browsing the legacy symfony 1.x part of this website.

День 6: Модель в подробностях

1.4 / Doctrine
Symfony version
1.2
Language ORM

Вчера был великий день. Мы изучили как использовать произвольные URL и Symfony в-целом для автоматизации многих ваших задач.

Сегодня мы улучшим сайт Jobeet, дополняя код в разных местах. В процессе работы Вы узнаете больше об особенностях, которые были представлены в первых пяти днях этого руководства.

The Doctrine Query Object

Из требований (день 2):

"Когда пользователь заходит на Jobeet, он видит список активных вакансий."

Но на данный момент все вакансии отбражаются независимо от того, активные они или нет:

// apps/frontend/modules/job/actions/actions.class.php
class jobActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
    $this->jobeet_jobs = Doctrine::getTable('JobeetJob')
      ->createQuery('a')
      ->execute();
  }
 
  // ...
}

Активной считается вакансия, созданная не более 30 дней назад. Метод Doctrine_Query::execute() создает запрос к базе. В коде, приведенном выше, мы не определили ни одного условия where , это означает что из базы должны быть получены все записи.

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

public function executeIndex(sfWebRequest $request)
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.created_at > ?', date('Y-m-d h:i:s', time() - 86400 * 30));
 
  $this->jobeet_jobs = $q->execute();
}

Отладка SQL сгенерированного при помощи Doctrine

Поскольку Вы не пишете SQL инструкции вручную, Doctrine берет заботу на себя о различиях между базами и генерирует SQL инструкции, оптимизированные для той СУБД, которую Вы используете. Но иногда, было бы очень полезно посмотреть SQL который генерируется Doctrine; например, чтобы отладить запрос который работает не так, как ожидается. В окружении dev, symfony логирует такие запросы (как и много другой информации) в каталоге log/ . Для каждой комбинации приложения и окружения существует отдельный лог. Файл, который нас интересует, называется frontend_dev.log:

# log/frontend_dev.log
Dec 04 13:58:33 symfony [info] {sfDoctrineLogger} executeQuery : SELECT 
j.id AS j__id, j.category_id AS j__category_id, j.type AS j__type, 
j.company AS j__company, j.logo AS j__logo, j.url AS j__url, 
j.position AS j__position, j.location AS j__location, 
j.description AS j__description, j.how_to_apply AS j__how_to_apply, 
j.token AS j__token, j.is_public AS j__is_public, 
j.is_activated AS j__is_activated, j.email AS j__email, 
j.expires_at AS j__expires_at, j.created_at AS j__created_at, 
j.updated_at AS j__updated_at FROM jobeet_job j 
WHERE j.created_at > ? (2008-11-08 01:13:35)

В нашем случае мы видим, что Doctrine сгенерировало условие where для колонки created_at (WHERE j.created_at > ?).

note

Строка ? в запросе свидетельствует о том что Doctrine генерирует предкомпилированные запросы (prepared statements). Реальное значение ? ('2008-11-08 01:13:35' в примере выше) подставляется во время выполнения запроса и экранируется встроенными средствами базы данных. Использование prepared statements практически сводит на нет угрозу атаки через SQL injection

Это все хорошо, но немного неудобно переключаться между браузером, IDE и файлом лога каждый раз, когда Вам нужно проверить изменения. Благодаря symfony web debug toolbar, вся информация которая Вам нужна удобно доступна через браузер:

SQL statements in the web debug toolbar

Сериализация

Наш код работает, но он еще далек от совершенства, так как не выполняет некоторые требования из дня 2:

"Пользователь может вернуться, чтобы активировать вакансию заново или продлить ее еще на 30 дней..."

Наша проверка основана только на значении created_at, и поскольку эта колонка содержит дату создания, мы не можем удовлетворить указанное требование.

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

Когда Вам нужно автоматически сделать что-нибудь перед тем как Doctrine сохранит объект в базу, Вы должны переопределить метод save() соответсвующего класса:

// lib/model/doctrine/JobeetJob.class.php
class JobeetJob extends BaseJobeetJob
{
  public function save(Doctrine_Connection $conn = null)
  {
    if ($this->isNew() && !$this->getExpiresAt())
    {
      $now = $this->getCreatedAt() ? $this->getDateTimeObject('created_at')->format('U') : time();
      $this->setExpiresAt(date('Y-m-d h:i:s', $now + 86400 * 30));
    }
 
    return parent::save($conn);
  }
 
  // ...
}

Метод isNew() возвращает true, если объект еще ни разу не сохранялся в базе, и false в противном случае.

Теперь давайте изменим поведение, чтобы использовалась колонка expires_at вместо created_at для получения активных вакансий:

public function executeIndex(sfWebRequest $request)
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()));
 
  $this->jobeet_jobs = $q->execute();
}

Мы ввели ограничение в запросе - выбирать вакансии, у которых expires_at находится в будущем.

Больше тестовых данных.

Обновив главную страницу Jobeet, Вы увидите, что ничего не изменилось, потому что вакансии в базе были созданы всего несколько дней назад. Давайте изменим fixtures, добавив вакансию, которая просрочена:

# data/fixtures/jobs.yml
JobeetJob:
  # other jobs
 
  expired_job:
    JobeetCategory: programming
    company:        Sensio Labs
    position:       Web Developer
    location:       Paris, France
    description:    Lorem ipsum dolor sit amet, consectetur adipisicing elit.
    how_to_apply:   Send your resume to lorem.ipsum [at] dolor.sit
    is_public:      true
    is_activated:   true
    created_at:     '2005-12-01 00:00:00'
    token:          job_expired
    email:          [email protected]

note

Будьте аккуратны, когда вставляете код в файл тестовых данных, не сломайте отступы. Перед expired_job должно стоять ровно 2 пробела.

В вакансии, которую мы добавили в файл, поле created_at может содержать значение, даже если оно автоматически заполняется через Doctrine. Определенное здесь значение перекроет значение по умолчанию. Перезагрузите тестовые данные и обновите браузер, чтобы убедиться что старые вакансии не отображаются:

$ php symfony doctrine:data-load

Вы можете выполнить следующий запрос, чтобы убедиться в том что поле expires_at автоматически заполняется методом save() на основании значения created_at:

SELECT `position`, `created_at`, `expires_at` FROM `jobeet_job`;

Конфигурирование.

В методе JobeetJob::save() мы жестко задали количество дней, через которое вакансия становится просроченой. Неплохо было бы сделать это значение конфигурируемым. symfony framework предоставляет встроенный конфигурационный файл для настроек приложения - app.yml. В этом YAML файле Вы можете хранить любые свои настройки:

# apps/frontend/config/app.yml
all:
  active_days: 30

В приложении эти настройки доступны через класс sfConfig:

sfConfig::get('app_active_days')

Параметр имеет префикс app_ потому что класс sfConfig также предоставляет доступ к настройкам symfony, которые будут рассмотрены позже.

Давайте допишем код, чтобы использовать этот новый параметр:

public function save(Doctrine_Connection $conn = null)
{
  if ($this->isNew() && !$this->getExpiresAt())
  {
    $now = $this->getCreatedAt() ? $this->getDateTimeObject('created_at')->format('U') : time();
    $this->setExpiresAt(date('Y-m-d h:i:s', $now + 86400 * sfConfig::get('app_active_days')));
  }
 
  return parent::save($conn);
}

Конфигурационный файл app.yml - отличный способ хранить глобальные настройки вашего приложения.

Рефакторинг

Несмотря на то, что написанный нами код неплохо работает, он пока еще не совсем правильный. Можете ли Вы найти в нем проблему?

Код Doctrine_Query не должен принадлежать слою Контроллера, он относится к слою Модели. В модели MVC, Модель определяет всю бизнес-логику, а Контроллер только вызывает Модель, чтобы получить данные. Поскольку наш код возвращает коллекцию вакансий, давайте перенесем его в класс JobeetJobTable и создадим метод getActiveJobs():

// lib/model/doctrine/JobeetJobTable.class.php
class JobeetJobTable extends Doctrine_Table
{
  public function getActiveJobs()
  {
    $q = $this->createQuery('j')
      ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()));
 
    return $q->execute();
  }
}

Теперь код контроллера может использовать этот новый метод для получения активных вакансий.

public function executeIndex(sfWebRequest $request)
{
  $this->jobeet_jobs = Doctrine_Core::getTable('JobeetJob')->getActiveJobs();
}

Проведенный рефакторинг имеет много преимуществ по сравнению со старым кодом:

  • Логика для получения активных вакансий находится в Модели, там где ей и положено быть.
  • Код в Контроллере более читаемый.
  • Метод getActiveJobs() повторно используемый (например, в другом обработчике событий)
  • Код модели теперь можно покрыть юнит-тестами.

Давайте отсортируем вакансии по колонке expires_at:

public function getActiveJobs()
{
  $q = $this->createQuery('j')
    ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()))
    ->orderBy('j.expires_at DESC');
 
  return $q->execute();
}

Метод orderBy добавляет конструкцию ORDER BY в итоговый SQL (так же есть метод addOrderBy()).

Категории на главной странице.

Требование (день 2):

"Вакансии отсортированы по категории и затем по дате создания (Новые вакансии вначале)."

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

Откройте класс JobeetCategoryTable и добавьте метод getWithJobs() :

// lib/model/doctrine/JobeetCategoryTable.class.php
class JobeetCategoryTable extends Doctrine_Table
{
  public function getWithJobs()
  {
    $q = $this->createQuery('c')
      ->leftJoin('c.JobeetJobs j')
      ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()));
 
    return $q->execute();
  }
}

Соответственно изменим index action:

// apps/frontend/modules/job/actions/actions.class.php
public function executeIndex(sfWebRequest $request)
{
  $this->categories = Doctrine_Core::getTable('JobeetCategory')->getWithJobs();
}

В шаблоне мы должны пройтись по всем категориям и вывести активные вакансии:

// apps/frontend/modules/job/indexSuccess.php
<?php use_stylesheet('jobs.css') ?>
 
<div id="jobs">
  <?php foreach ($categories as $category): ?>
    <div class="category_<?php echo Jobeet::slugify($category->getName()) ?>">
      <div class="category">
        <div class="feed">
          <a href="">Feed</a>
        </div>
        <h1><?php echo $category ?></h1>
      </div>
 
      <table class="jobs">
        <?php foreach ($category->getActiveJobs() as $i => $job): ?>
          <tr class="<?php echo fmod($i, 2) ? 'even' : 'odd' ?>">
            <td class="location"><?php echo $job->getLocation() ?></td>
            <td class="position"><?php echo link_to($job->getPosition(), 'job_show_user', $job) ?></td>
            <td class="company"><?php echo $job->getCompany() ?></td>
          </tr>
        <?php endforeach; ?>
      </table>
    </div>
  <?php endforeach; ?>
</div>

note

Чтобы вывести категории в шаблоне, мы используем echo $category. Не выглядит ли это странно? $category является объектом, как echo волшебным образом выводит название категории? Ответ кроется в Дне 3, когда мы определили magic метод __toString() для всех классов модели.

Для того чтобы все это работало, мы должны добавить метод getActiveJobs() в класс JobeetCategory :

// lib/model/doctrine/JobeetCategory.class.php
public function getActiveJobs()
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.category_id = ?', $this->getId());
 
  return Doctrine_Core::getTable('JobeetJob')->getActiveJobs($q);
}

Метод JobeetCategory::getActiveJobs() использует Doctrine::getTable('JobeetJob')->getActiveJobs() для получения активных вакансий в данной категории.

Когда мы вызываем Doctrine::getTable('JobeetJob')->getActiveJobs(), мы хотим ограничить условие выборки, передавая категорию. Вместо того чтобы передавать объект category, мы решили передавать объект Doctrine_Query, потому что это лучший способ инкапсулировать произвольное условие.

Метод getActiveJobs() должен объединить переданный ему объект Doctrine_Query с остальным запросом. Делается это весьма просто:

// lib/model/doctrine/JobeetJobTable.class.php
public function getActiveJobs(Doctrine_Query $q = null)
{
  if (is_null($q))
  {
    $q = Doctrine_Query::create()
      ->from('JobeetJob j');
  }
 
  $q->andWhere('j.expires_at > ?', date('Y-m-d h:i:s', time()))
    ->addOrderBy('j.expires_at DESC');
 
  return $q->execute();
}

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

Нужно реализовать еще одно требование, которому удолжен удовлетворять список вакансий на главной странице:

"Для каждой категории, в списке отображаются первые 10 вакансий и ссылка, которая позволяет просмотреть все вакансии в данной категории."

Реализуется достаточно просто в методе getActiveJobs():

public function getActiveJobs($max = 10)
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.category_id = ?', $this->getId())
    ->limit($max);

  return Doctrine_Core::getTable('JobeetJob')->getActiveJobs($q);
}

Соответствующая конструкция LIMIT теперь жестко задана в Модели, но было бы лучше сделать это значение конфигурируемым. Измените шаблон, чтобы в нем передавалось максимальное количество вакансий заданное в app.yml:

<!-- apps/frontend/modules/job/indexSuccess.php -->
<?php foreach ($category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')) as $i => $job): ?>

и добавьте новую настройку в app.yml:

all:
  active_days:          30
  max_jobs_on_homepage: 10

Homepage sorted by category

Динамические тестовые данные.

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

Нас спасает symfony! Файлы YAML в symfony могут содержать PHP код, который будет выполнен перед загрузкой информации из файла. Откройте файл jobs.yml и добавьте следующий код в конец:

JobeetJob:
# Starts at the beginning of the line (no whitespace before)
<?php for ($i = 100; $i <= 130; $i++): ?>
  job_<?php echo $i ?>:
    JobeetCategory: programming
    company:      Company <?php echo $i."\n" ?>
    position:     Web Developer
    location:     Paris, France
    description:  Lorem ipsum dolor sit amet, consectetur adipisicing elit.
    how_to_apply: |
      Send your resume to lorem.ipsum [at] company_<?php echo $i ?>.sit
    is_public:    true
    is_activated: true
    token:        job_<?php echo $i."\n" ?>
    email:        [email protected]
 
<?php endfor ?>

Будьте внимательны, парсеру YAML не понравится, если Вы напутаете с отступами. Помните эти простые советы, когда добавляете код PHP в файл YAML:

  • Тэги <?php ?> должны быть в самом начале строки, либо быть частью значения
  • Если <?php ?> расположен в конце строки, Вы должны явно вывести перевод строки ("\n").

Теперь Вы можете перезагрузить тестовые данные, введя команду doctrine:data-load и проверить отображается ли ровно 10 вакансий на главной странице в категории Programming. На скриншоте мы уменьшили количество вакансий до 5, чтобы сделать картинку меньше:

Pagination

Обезопасим страницу вакансии.

Когда вакансия истекает, она не должна быть доступна, даже если Вы попытаетесь открыть ее по прямой ссылке. Откройте url просроченой вакансии (замените значение id на актуальное из вашей базы - SELECTid,tokenFROMjobeet_jobWHEREexpires_at< NOW()):

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

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

# apps/frontend/config/routing.yml
job_show_user:
  url:     /job/:company_slug/:location_slug/:id/:position_slug
  class:   sfDoctrineRoute
  options:
    model: JobeetJob
    type:  object
    method_for_query: retrieveActiveJob
  param:   { module: job, action: show }
  requirements:
    id: \d+
    sf_method: [GET]

Метод retrieveActiveJob() получает объект Doctrine_Query сформированный данным маршрутом:

// lib/model/doctrine/JobeetJobTable.class.php
class JobeetJobTable extends Doctrine_Table
{
  public function retrieveActiveJob(Doctrine_Query $q)
  {
    $q->andWhere('a.expires_at > ?', date('Y-m-d h:i:s', time()));
 
    return $q->fetchOne();
  }
 
  // ...
}

Теперь, если Вы попытаетесь открыть просроченную вакансию, Вы будете перенаправлены на страницу 404.

404 for expired job

Ссылка на страницу категории.

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

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

Увидимся завтра!

Сделайте задачу в вашем локальном проекте. В вашем распоряжении есть онлайн API документация и вся свободная докуменация, доступная на сайте symfony. Завтра мы увидимся и рассмотрим наш вариант реализации этой задачи.

Good luck!