English spoken conference

Symfony 5: The Fast Track

A new book to learn about developing modern Symfony 5 applications.

Support this project

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

День 7: Категории

1.4 / Propel
Symfony version
1.2
Language ORM

Мы вчера много узнали о symfony: получение данных с помощью Propel, фикстуры (начальные и тестовые данные), маршрутизация, отладка и конфигурация проекта.

Если Вы вчера поработали самостоятельно, Вы лучше воспримите этот урок.

Итак, рассмотрим возможный вариант реализации.

Маршрутизация для страницы категорий

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

# apps/frontend/config/routing.yml
category:
  url:      /category/:slug
  class:    sfPropelRoute
  param:    { module: category, action: show }
  options:  { model: JobeetCategory, type: object }

tip

Вообще, лучше всегда сначала подумать об URL и создать соответствующее правило маршрутизации перед тем, как реализовывать новую задачу. И конечно, это обязательное условие, если Вы удалили дефолтные правила из routing.yml.

В правиле маршрутизации, в качестве параметра запроса, можно использовать любое свойство связанного объекта. А также, можно использовать любое произвольное значение, если связанный объект имеет соответствующий геттер. Поскольку в таблице category нет колонки slug, нам необходимо добавить соответствующий геттер в JobeetCategory, чтобы правило заработало:

// lib/model/JobeetCategory.php
public function getSlug()
{
  return Jobeet::slugify($this->getName());
}

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

Теперь измените шаблон job/indexSuccess.php, чтобы добавить ссылку на страницу категорий.

<!-- some HTML code -->
 
        <h1>
          <?php echo link_to($category, 'category', $category) ?>
        </h1>
 
<!-- some HTML code -->
 
      </table>
 
      <?php if (($count = $category->countActiveJobs() - sfConfig::get('app_max_jobs_on_homepage')) > 0): ?>
        <div class="more_jobs">
          and <?php echo link_to($count, 'category', $category) ?>
          more...
        </div>
      <?php endif; ?>
    </div>
  <?php endforeach; ?>
</div>

Мы добавляем ссылку только в том случае, если в конкретной категории содержится больше 10 вакансий. В тексте ссылки укажем число оставшихся вакансий, которые не поместились в общий список. Чтобы этот шаблон смог работать, добавим метод countActiveJobs() в JobeetCategory:

// lib/model/JobeetCategory.php
public function countActiveJobs()
{
  $criteria = new Criteria();
  $criteria->add(JobeetJobPeer::CATEGORY_ID, $this->getId());
 
  return JobeetJobPeer::countActiveJobs($criteria);
}

Метод JobeetCategory::countActiveJobs() использует не существующий пока метод JobeetJobPeer::countActiveJobs(). Добавим следующий код в JobeetJobPeer.php:

// lib/model/JobeetJobPeer.php
class JobeetJobPeer extends BaseJobeetJobPeer
{
  static public function getActiveJobs(Criteria $criteria = null)
  {
    return self::doSelect(self::addActiveJobsCriteria($criteria));
  }
 
  static public function countActiveJobs(Criteria $criteria = null)
  {
    return self::doCount(self::addActiveJobsCriteria($criteria));
  }
 
  static public function addActiveJobsCriteria(Criteria $criteria = null)
  {
    if (is_null($criteria))
    {
      $criteria = new Criteria();
    }
 
    $criteria->add(self::EXPIRES_AT, time(), Criteria::GREATER_THAN);
    $criteria->addDescendingOrderByColumn(self::CREATED_AT);
 
    return $criteria;
  }
 
  static public function doSelectActive(Criteria $criteria)
  {
    return self::doSelectOne(self::addActiveJobsCriteria($criteria));
  }
}

Как Вы видите, мы полностью отрефакторили JobeetJobPeer, чтобы дополнительно выделить метод addActiveJobsCriteria(). Это позволит обойти дублирование и сделает код более DRY (Don't Repeat Yourself).

tip

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

В методе JobeetJobPeer::countActiveJobs() вместо того, чтобы использовать doSelect() и считать кол-во возвращенных элементов, мы использовали более эффективный метод doCount(), который сразу возвращает необходимый результат.

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

Homepage

Создание модуля для категорий

Теперь создадим отдельный модуль для категорий:

$ php symfony generate:module frontend category

Возможно, для создания модуля Вы будете использовать команду propel:generate-module. Но, поскольку нам не потребуется 90% сгенеренного кода, я буду использовать generate:module, который просто создаст пустой модуль.

tip

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

Когда мы запрашиваем страницу категории, маршрут category будет пытаться найти соответствующий объект по параметру slug. Поскольку slug не хранится в базе данных, и мы не можем вычислить категорию зная slug, найти объект по slug оказывается невозможным.

Редактирование базы данных

Нам необходимо добавить колонку slug в таблицу category:

# config/schema.yml
propel:
  jobeet_category:
    id:           ~
    name:         { type: varchar(255), required: true }
    slug:         { type: varchar(255), required: true, index: unique }

Теперь, когда slug появился в базе данных, мы можем удалить метод JobeetCategory::getSlug()

Каждый раз, когда изменяется название категории, нам необходимо обновить slug. Давайте отредактируем метод JobeetCategory::setName():

// lib/model/JobeetCategory.php
public function setName($name)
{
  parent::setName($name);
 
  $this->setSlug(Jobeet::slugify($name));
}

Используйте команду propel:build --all --and-load, чтобы обновить таблицы базы данных и заполнить тестовыми данными (фикстурой).

$ php symfony propel:build --all --and-load --no-confirmation

Теперь все готово, чтобы создать метод executeShow(). Замените содержимое контроллера category следующим кодом:

// apps/frontend/modules/category/actions/actions.class.php
class categoryActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->category = $this->getRoute()->getObject();
  }
}

note

Поскольку мы удалили метод executeIndex(), также мы можем удалить автоматически созданный шаблон indexSuccess.php (apps/frontend/modules/category/templates/indexSuccess.php).

И напоследок, остается создать шаблон showSuccess.php:

// apps/frontend/modules/category/templates/showSuccess.php
<?php use_stylesheet('jobs.css') ?>
 
<?php slot('title', sprintf('Jobs in the %s category', $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>

Partial (Фрагмент шаблона)

Обратите внимание, что мы скопировали часть шаблона, ответственного за отрисовку списка вакансий из job/indexSuccess.php. Но это не эффективно. Пришло время познакомиться с новым приемом: когда Вам необходимо повторно использовать какую-то часть шаблона, Вы можете выделить ее в partial. Это фрагмент кода, который может быть использован в нескольких шаблонах. Практически это такой же шаблон, только название файла начинается со знака подчеркивания (_).

Создайте файл job/templates/_list.php:

// apps/frontend/modules/job/templates/_list.php
<table class="jobs">
  <?php foreach ($jobs 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>

Чтобы подключить partial используйте хелпер include_partial():

<?php include_partial('job/list', array('jobs' => $jobs)) ?>

В качестве первого аргумента include_partial() используется связка "модуль/partial" (знак подчеркивания в названии partial'а пропускается). Второй аргумент - это массив переменных, которые должны быть переданы в partial.

note

Почему мы используем хелпер include_partial() вместо того, чтобы использовать родную конструкцию PHP include()? Принципиальное отличие include_partial() - это встроенная поддержка кеширования шаблонов.

Замените соответствующий HTML-код в обоих шаблонах на вызов include_partial():

// in apps/frontend/modules/job/templates/indexSuccess.php
<?php include_partial('job/list', array('jobs' => $category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')))) ?>
 
// in apps/frontend/modules/category/templates/showSuccess.php
<?php include_partial('job/list', array('jobs' => $category->getActiveJobs())) ?>

Постраничная навигация

В соответствии с требованиями, описанными в "День 2":

"Список разбивается на страницы по 20 вакансий"

Для постраничной выборки объектов для Propel в symfony используется класс sfPropelPager. Теперь, вместо того, чтобы передавать в шаблон category/showSuccess массив объектов JobeetJob, мы будем передавать pager:

// apps/frontend/modules/category/actions/actions.class.php
public function executeShow(sfWebRequest $request)
{
  $this->category = $this->getRoute()->getObject();
 
  $this->pager = new sfPropelPager(
    'JobeetJob',
    sfConfig::get('app_max_jobs_on_category')
  );
  $this->pager->setCriteria($this->category->getActiveJobsCriteria());
  $this->pager->setPage($request->getParameter('page', 1));
  $this->pager->init();
}

tip

Метод sfRequest::getParameter() вторым аргументом принимает значение параметра по-умолчанию. Если параметр page отсутствует, тогда getParameter() вернет 1.

Конструктор sfPropelPager в качестве аргументов принимает класс модели и кол-во элементов на страницу. Перенесем в конфиг соответствующее настройки для постраничной навигации:

# apps/frontend/config/app.yml
all:
  active_days:          30
  max_jobs_on_homepage: 10
  max_jobs_on_category: 20

Метод sfPropelPager::setCriteria() принимет объект Criteria для ограничения выборки.

Добавим метод JobeetCategory::getActiveJobsCriteria():

// lib/model/JobeetCategory.php
public function getActiveJobsCriteria()
{
  $criteria = new Criteria();
  $criteria->add(JobeetJobPeer::CATEGORY_ID, $this->getId());
 
  return JobeetJobPeer::addActiveJobsCriteria($criteria);
}

Теперь, когда мы определили метод getActiveJobsCriteria(), мы можем отрефакторить класс JobeetCategory:

// lib/model/JobeetCategory.php
public function getActiveJobs($max = 10)
{
  $criteria = $this->getActiveJobsCriteria();
  $criteria->setLimit($max);
 
  return JobeetJobPeer::doSelect($criteria);
}
 
public function countActiveJobs()
{
  $criteria = $this->getActiveJobsCriteria();
 
  return JobeetJobPeer::doCount($criteria);
}

И, в заключение, отредактируем шаблон:

<!-- apps/frontend/modules/category/templates/showSuccess.php -->
<?php use_stylesheet('jobs.css') ?>
 
<?php slot('title', sprintf('Jobs in the %s category', $category->getName())) ?>
 
<div class="category">
  <div class="feed">
    <a href="">Feed</a>
  </div>
  <h1><?php echo $category ?></h1>
</div>
 
<?php include_partial('job/list', array('jobs' => $pager->getResults())) ?>
 
<?php if ($pager->haveToPaginate()): ?>
  <div class="pagination">
    <a href="<?php echo url_for('category', $category) ?>?page=1">
      <img src="/legacy/images/first.png" alt="First page" title="First page" />
    </a>
 
    <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getPreviousPage() ?>">
      <img src="/legacy/images/previous.png" alt="Previous page" title="Previous page" />
    </a>
 
    <?php foreach ($pager->getLinks() as $page): ?>
      <?php if ($page == $pager->getPage()): ?>
        <?php echo $page ?>
      <?php else: ?>
        <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $page ?>"><?php echo $page ?></a>
      <?php endif; ?>
    <?php endforeach; ?>
 
    <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getNextPage() ?>">
      <img src="/legacy/images/next.png" alt="Next page" title="Next page" />
    </a>
 
    <a href="<?php echo url_for('category', $category) ?>?page=<?php echo $pager->getLastPage() ?>">
      <img src="/legacy/images/last.png" alt="Last page" title="Last page" />
    </a>
  </div>
<?php endif; ?>
 
<div class="pagination_desc">
  <strong><?php echo count($pager) ?></strong> jobs in this category
 
  <?php if ($pager->haveToPaginate()): ?>
    - page <strong><?php echo $pager->getPage() ?>/<?php echo $pager->getLastPage() ?></strong>
  <?php endif; ?>
</div>

Большая часть кода оперирует ссылками на другие страницы. Ниже представлен список методов sfPropelPager, использованных в шаблоне:

  • getResults(): Возвращает массив Propel-объектов для текущей страницы
  • getNbResults(): Общее количество найденных объектов
  • haveToPaginate(): Возвращает true, если количество страниц больше 1
  • getLinks(): Массив ссылок на все страницы
  • getPage(): Текущий номер страницы
  • getPreviousPage(): Номер предыдущей страницы
  • getNextPage(): Номер следующей страницы
  • getLastPage(): Номер последней страницы

Поскольку класс sfPropelPager также реализует интерфейсы Iterator и Countable, Вы можете использовать функцию count(), чтобы получить количество объектов, вместо метода getNbResults().

Pagination

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

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

Завтра мы поговорим о совершенно новой теме - о тестировании приложения.