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

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

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

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

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

カテゴリのルート

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

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

tip

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

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

// lib/model/doctrine/JobeetCategory.class.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を越える場合のみにリンクを表示します。 リンクは表示されない求人件数を含みます。 このテンプレートを動作させるために、JobeetCategorycountActiveJobs()メソッドを追加する必要があります:

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

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

// lib/model/doctrine/JobeetJobTable.class.php
class JobeetJobTable extends Doctrine_Table
{
  public function retrieveActiveJob(Doctrine_Query $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()メソッドにおいて、execute()を使って結果数をカウントする代わりに、はるかに速いcount()メソッドを使いました。

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

ホームページ

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

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

$ php symfony generate:module frontend category

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

tip

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

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

データベースを更新する

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

このslugカラムはDoctrineのSluggableビヘイビアによって考慮されます。 JobeetCategoryモデルのビヘイビアを有効にすればすべてが考慮されます。

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

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

note

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

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

$ php symfony doctrine:build-all-reload --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>

パーシャル

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

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

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

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

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

note

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

両方からのHTMLコードの<table>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件の求人でページ分割される"

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

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

tip

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

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

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

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

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

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

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

// 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();
}

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

<!-- 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" />
    </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 $pager->getNbResults() ?></strong> jobs in this category
 
  <?php if ($pager->haveToPaginate()): ?>
    - page <strong><?php echo $pager->getPage() ?>/<?php echo $pager->getLastPage() ?></strong>
  <?php endif; ?>
</div>

たいていのコードでは他のページへのリンクが扱われます。 このテンプレートで使われるsfDoctrinePagerメソッドのリストは次のとおりです:

  • getResults(): 現在のページ用のDoctrineオブジェクトを返す
  • getNbResults(): 結果の合計数を返す
  • haveToPaginate(): 複数のページがある場合はtrueを返す
  • getLinks(): 表示するページリンクのリストを返す
  • getPage(): 現在のページ番号を返す
  • getPreviousPage(): 前のページ番号を返す
  • getNextPage(): 次のページ番号を返す
  • getLastPage(): 最後のページ番号を返す

ページ分割

また明日

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

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