7日目: カテゴリページで遊ぶ

昨日はたくさんの異なる領域: ##ORM## でクエリを行う、フィクスチャ、ルーティング、デバッグとカスタムの設定など symfony の知識を広げました。今日は少しチャレンジして終わります。

あなたが Jobeet のカテゴリページで取り組んでくださることを期待しています。今日のチュートリアルはさらに大切になります。

準備はいいですか?実現可能な実装について語りましょう。

カテゴリのルート

最初に、カテゴリページに対してプリティ URL を定義するためにルートを定義する必要があります。ルーティングファイルの冒頭で次の内容を追加します:

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

TIP 新しい機能を実装し始めるとき、最初に ~URL~ を考えて関連~ルート~を作るのはよい習慣です。デフォルトのルーティングルールを削除するとき、このルートは必須です。

ルートは関連オブジェクトからの任意のカラムをパラメータとして使うことができます。オブジェクトクラスで定義される関連アクセサが存在する場合、ルートはほかの値も利用できます。slug パラメータは対応する category テーブルのカラムをもたないので、ルートが機能するようにするために JobeetCategory のバーチャルアクセサを追加する必要があります:

[php]

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

カテゴリのリンク

リンクをカテゴリページに追加するために job モジュールの indexSuccess.php テンプレートを編集します:

[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 に 追加する必要があります:

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

  return JobeetJobPeer::countActiveJobs($criteria);
}

countActiveJobs() メソッドは JobeetJobPeer にはまだ存在しない countActiveJobs() メソッドを利用します。JobeetJobPeer.php ファイルの内容を次のコードで置き換えます: [php] // lib/model/doctrine/JobeetCategory.class.php public function countActiveJobs() { $q = DoctrineQuery::create() ->from('JobeetJob j') ->where('j.categoryid = ?', $this->getId());

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

countActiveJobs() メソッドは JobeetJobTable にまだ存在しない countActiveJobs() メソッドを利用します。JobeetJobTable.php ファイルの内容を次のコードで置き換えます:

[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));
  }
}

ご覧のとおり、コードをより ~DRY~ (Don't Repeat Yourself) にするために、新しく共有される addActiveJobsCriteria() メソッドを導入して JobeetJobPeer のコード全体をリファクタリングしました。

[php] // lib/model/doctrine/JobeetJobTable.class.php class JobeetJobTable extends DoctrineTable { public function retrieveActiveJob(DoctrineQuery $q) { return $this->addActiveJobsQuery($q)->fetchOne(); }

  public function getActiveJobs(Doctrine_Query $q = null)
  {
    return $this->addActiveJobsQuery($q)->execute();
  }

  public function countActiveJobs(Doctrine_Query $q = null)
  {
    return $this->addActiveJobsQuery($q)->count();
  }

  public function addActiveJobsQuery(Doctrine_Query $q = null)
  {
    if (is_null($q))
    {
      $q = Doctrine_Query::create()
        ->from('JobeetJob j');
    }

    $alias = $q->getRootAlias();

    $q->andWhere($alias . '.expires_at > ?', date('Y-m-d H:i:s', time()))
      ->addOrderBy($alias . '.created_at DESC');

    return $q;
  }
}

ご覧の通り、コードをより ~DRY~ (Don't Repeat Yourself) にするために、新しく共有する addActiveJobsQuery() メソッドを導入して JobeetJobTable のコード全体をリファクタリングしました。

TIP 最初にコードのピースが再利用されるとき、コードのコピーで間に合うかもしれません。しかしそれをほかの場所で使う場合、今しがた作業したように、共用の関数もしくはメソッドへのすべての利用をリファクタリングする必要があります。

countActiveJobs() メソッドにおいて、doSelect() を使って結果数をカウントする代わりに、ずっと速い doCount() メソッドを使いました。 countActiveJobs() メソッドにおいて、execute() を使って結果数をカウントする代わりに、はるかに速い count() メソッドを使いました。

このシンプルな機能のために、たくさんのファイルを変更しました。コードを追加するたびに、アプリケーションの正しいレイヤーに設置しようとしました。コードを再利用できるようにすることにも取り組みました。このプロセスにおいて、既存のコードをリファクタリングすることも行いました。これは symfony プロジェクトに取り組む際の典型的なワークフローです。次のスクリーンショットでは短くするために5件の求人を表示しており、10件を見ることになります (max_jobs_on_homepage 設定):

ホームページ

求人の category モジュールの作成

category ~モジュール~を作りましょう:

$ php symfony generate:module frontend category

モジュールを作ってあるのであれば、おそらく propel:generate-module を使ったことでしょう。これはよいのですが、生成コードの90%は不要なので、空のモジュールを作る generate:module を使いました。

TIP category アクションを job モジュールに 追加しないのはなぜでしょうか?できますが、カテゴリページのメインの対象はカテゴリなので、専用の category モジュールを作るのは自然に思われるからです。

カテゴリページにアクセスする際に、category ルートは slug リクエスト変数に関連するカテゴリを見つけなければなりません。~スラッグ~はデータベースに保存されないのとスラッグからカテゴリの名前を推測できないので、スラッグに関連するカテゴリを見つける方法がありません。

データベースを更新する

category テーブルのために slug カラムを追加する必要があります:

[yml] # config/schema.yml propel: jobeet_category: id: ~ name: { type: varchar(255), required: true } slug: { type: varchar(255), required: true, index: unique } この slug カラムは Doctrine の Sluggable ビヘイビアによって考慮されます。JobeetCategory モデルのビヘイビアを有効にすればすべてが考慮されます。

[yml]
# config/doctrine/schema.yml
JobeetCategory:
  actAs:
    Timestampable: ~
    Sluggable:
      fields: [name]
  columns:
    name:
      type: string(255)
      notnull:  true

slug は本当のカラムなので、JobeetCategory から getSlug() メソッドを削除する必要があります。

category の名前が代わるたびに、slug を算出して変更する必要があります。setName() メソッドをオーバーライドしてみましょう:

[php]
// lib/model/JobeetCategory.php
public function setName($name)
{
  parent::setName($name);

  $this->setSlug(Jobeet::slugify($name));
}

NOTE レコードを保存する際に slug カラムの設定は自動的に考慮されます。name フィールドの値を使ってスラッグはビルドされオブジェクトに設定されます。

データベースのテーブルを更新するには propel:build --all --and-load タスクを使い、データベースにフィクスチャを投入します:

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

executeShow() メソッドを作るための場所が手に入りました。次のコードで category アクションファイルの内容を置き換えます:

[php]
// 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 テンプレートを作ることです:

[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>

~パーシャル|パーシャルテンプレート~

job の indexSuccess.php テンプレートから求人リストを作成する <table> タグをコピー&ペーストしたことに注目してください。これはよいことではありません。新しいトリックを学びましょう。テンプレートの一部を再利用する必要があるとき、~パーシャル|パーシャルテンプレート~ (partial) を作る必要があります。パーシャルは複数のテンプレートの間で共有できる~テンプレート~コードスニペットです。アンダースコア (_) で始まる別の種類のテンプレートにすぎません。

_list.php ファイルを作ります:

[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>

~include_partial()~ ヘルパーを利用することでパーシャルをインクルードできます:

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

include_partial() の最初の引数はパーシャルの名前です (モジュールの名前、/ と先頭の _ がないパーシャルの名前で構成)。第2引数はパーシャルに渡される変数の配列です。

NOTE include_partial() ヘルパーの代わりに PHP 組み込みの include() 関数を使わないのはなぜでしょうか?2つの主な違いは include_partial() ヘルパーの組み込みのキャッシュサポートです。

両方からの HTML コードの <table>include_partial() の呼び出しに置き換えます:

[php]
// 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件の求人でページ分割される」

ORM## オブジェクトのリストをページ分割するために、symfony は専用のクラス: sfPropelPager を提供します。category アクションにおいて、showSuccess テンプレートに job オブジェクトを渡す代わりに、ページャを渡します:

[php]
// 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->setQuery($this->category->getActiveJobsQuery()); $this->pager->setPage($request->getParameter('page', 1)); $this->pager->init(); }

TIP sfRequest::getParameter() メソッドはデフォルトの値を第2引数として受け取ります。上記のアクションにおいて、page リクエストパラメータが存在しない場合、getParameter()1を返します。

sfPropelPager のコンストラクタはモデルクラスとページごとに返すアイテムの最大個数を受け取ります。後者の値を設定ファイルに追加します:

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

sfPropelPager::setCriteria() メソッドはデータベースからアイテムを SELECT する際に使う Criteria オブジェクトを受け取ります。 sfDoctrinePager::setQuery() メソッドはデータベースからアイテムを SELECT する際に使う Doctrine_Query オブジェクトを受け取ります。

getActiveJobsCriteria() メソッドを追加します: getActiveJobsQuery() メソッドを追加します:

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

  return JobeetJobPeer::addActiveJobsCriteria($criteria);
}

[php] // lib/model/doctrine/JobeetCategory.class.php public function getActiveJobsQuery() { $q = DoctrineQuery::create() ->from('JobeetJob j') ->where('j.categoryid = ?', $this->getId());

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

getActiveJobsCriteria() メソッドを定義したので、このメソッドを使うようにするために、JobeetCategory メソッドをリファクタリングできます: getActiveJobsQuery() メソッドを定義したので、このメソッドを使うようにするために、JobeetCategory メソッドをリファクタリングできます:

[php] // 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);
}

[php] // lib/model/doctrine/JobeetCategory.class.php public function getActiveJobs($max = 10) { $q = $this->getActiveJobsQuery() ->limit($max);

  return $q->execute();
}

public function countActiveJobs()
{
  return $this->getActiveJobsQuery()->count();
}

最後に、テンプレートを更新しましょう:

[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>

<?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="/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="/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="/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="/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(): 現在のページ用の ##ORM## オブジェクトを返す
  • getNbResults(): 結果の合計数を返す
  • haveToPaginate(): 複数のページがある場合は true を返す
  • getLinks(): 表示するページリンクのリストを返す
  • getPage(): 現在のページ番号を返す
  • getPreviousPage(): 前のページ番号を返す
  • getNextPage(): 次のページ番号を返す
  • getLastPage(): 最後のページ番号を返す

sfPropelPagerIteratorCountable インターフェイスも実装するので、結果の数を得るのに getNbResults メソッドの代わりに count() 関数を使うことができます。

ページ送り

また明日

昨日独自の実装に取り組んだのであれば今日はあまり学ばなかったと感じるでしょう。これは symfony の哲学に慣れつつあることを意味します。symfony の Web サイトに新しい機能を追加するプロセスは常に同じです: URL を考え、アクションを作り、モデルを更新し、テンプレートを書きます。そして、よい開発習慣を複数の事例に適用できるのであれば、早く symfony マスターになれます。

明日は Jobeet の新しい週の始まりです。祝うために、真新しいトピック: テストを語ります。

ORM