モノノフ日記

普通の日記です

Jobeet - 6日目: モデルの詳細

Day 6: More with the Model (1_2) - Symfony

あとsymfony1.2.1がリリースされているのでupgradeしましょう。今日は比較的ボリューム少なめでした。

前回までのJobeet

昨日はすばらしい日でした。きれいなURLの作り方とたくさんの事を自動で行うsymfonyフレームワークの使い方を学習しました。
本日はコードのあちこちを修正してJobeetサイトをより良いものにしていきます。今週紹介した全ての要素についてさらに学習できるでしょう。

Propel Criteriaオブジェクト

2日目の必要要件を下記に示します。
"Jobeetサイトにユーザが訪れたとき、利用可能な仕事の一覧が見れます。"
しかし現在のところ、利用可能かどうかは関係なく全ての仕事が表示されます。

<?php
class jobActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
    $this->jobs = JobeetJobPeer::doSelect(new Criteria());
  }
 
  // ...
}

利用可能な仕事とは投稿されてから30日以内のものを指します。doSelect()メソッドはデータベースに対しリクエストを記述してCriteriaオブジェクトを取ってきます。上記のコード内で空のCriteriaオブジェクトが渡されています、このことはデータベースから全てのレコードを取得するということを意味しています。
利用可能な仕事だけを選択するように変更してみましょう。

<?php
public function executeIndex(sfWebRequest $request)
{
  $criteria = new Criteria();
  $criteria->add(JobeetJobPeer::CREATED_AT, time() - 86400 * 30, Criteria::GREATER_THAN);
 
  $this->jobs = JobeetJobPeer::doSelect($criteria);
}

Criteria:add()メソッドは生成されたSQLにWHERE句を追加します。ここでは、Criteriaを30日以内に投稿された仕事のみに制限します。
このメソッドはたくさんの異なった比較演算子を持っており、addメソッドはその中の1つです。

  • Criteria::EQUAL
  • Criteria::NOT_EQUAL
  • Criteria::GREATER_THAN, Criteria::GREATER_EQUAL
  • Criteria::LESS_THAN, Criteria::LESS_EQUAL
  • Criteria::LIKE, Criteria::NOT_LIKE
  • Criteria::CUSTOM
  • Criteria::IN, Criteria::NOT_IN
  • Criteria::ISNULL, Criteria::ISNOTNULL
  • Criteria::CURRENT_DATE, Criteria::CURRENT_TIME, Criteria::CURRENT_TIMESTAMP

Propelで生成されたSQLデバッグ

手書きでSQLを記述しないので、Propelは異なるデータベース間の違いを管理して、3日目に選んだデータベースエンジンに最適化されたSQL文を生成します。しかし時に、Propelが生成したSQL文を見ることは非常に役に立ちます。例えば、予想通りに動かないcriteriaをデバッグするとき、などです。開発環境ではlog/ディレクトリ内にこれらのクエリのログを取ります。アプリケーションと環境の全ての組み合わせのログファイルがあります。探しているのはfrontend_dev.logというな名前のファイルです。

# log/frontend_dev.log
Dec 6 15:47:12 symfony [debug] {sfPropelLogger} exec: SET NAMES 'utf8'
Dec 6 15:47:12 symfony [debug] {sfPropelLogger} prepare: SELECT jobeet_job.ID, jobeet_job.CATEGORY_ID, jobeet_job.TYPE, jobeet_job.COMPANY, jobeet_job.LOGO, jobeet_job.URL, jobeet_job.POSITION, jobeet_job.LOCATION, jobeet_job.DESCRIPTION, jobeet_job.HOW_TO_APPLY, jobeet_job.TOKEN, jobeet_job.IS_PUBLIC, jobeet_job.CREATED_AT, jobeet_job.UPDATED_AT FROM `jobeet_job` WHERE jobeet_job.CREATED_AT>:p1
Dec 6 15:47:12 symfony [debug] {sfPropelLogger} Binding '2008-11-06 15:47:12' at position :p1 w/ PDO type PDO::PARAM_STR

Propelがcreated_atカラム用にWHERE句を作るのを確認できます(WHERE jobeet_job.CREATED_AT > :p1)。

クエリ内の:p1文字列はPropelが生成するプリペアドステートメントを示します。:p1の実際の値(上記の例では'2008-11-06')はクエリの実行の間に渡され、データベースエンジンによって適切にエスケープされます。プリペアドステートメントを使うことでSQLインジェクションの脅威を激減させます。

このことは良いことです。しかし変更点のテストのため毎回ブラウザ、IDE、ログファイルの間を行ったりきたりするのは少し面倒です。symfonyのwebデバッグツールバーのおかげで、必要な全ての情報がブラウザ上で快適に利用できます。

オブジェクトのシリアライズ

たとえ上記のコードが動いたとしても、思ったように動きません。2日目の必要要件には
"ユーザは再度仕事を有効にしたり、30日間延長できます。"
created_atカラムはレコードが作られた時点から変更すべきではないので、上記の要件は現在のコードでは実行できません。
もしデータベーススキーマを覚えているなら、expires_atカラムがあったのを覚えていると思います。現在、この値は常に空です。仕事が作られたとき、現在の日付の30日後をセットしなければなりません。Propelオブジェクトの前でそれらを実行してデータベースへシリアライズします。save()メソッドをオーバーライドすれば可能です。

<?php
// lib/model/JobeetJob.php
class JobeetJob extends BaseJobeetJob
{
  public function save(PropelPDO $con = null)
  {
    if ($this->isNew() && !$this->getExpiresAt())
    {
      $now = $this->getCreatedAt() ? $this->getCreatedAt('U') : time();
      $this->setExpiresAt($now + 86400 * 30);
    }
 
    return parent::save($con);
  }
 
  // ...
}

isNew()メソッドはデータベースにオブジェクトがまだシリアライズされていないときtrueを返し、それ以外はfalseを返します。
アクションでexpires_atカラムを使うように変更してみましょう。

<?php
public function executeIndex(sfWebRequest $request)
{
  $criteria = new Criteria();
  $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
 
  $this->jobs = JobeetJobPeer::doSelect($criteria);
}

将来、expires_atの日付で選択した仕事のみを対象としたcriteriaを制限します。

Fixturesのさらなる使い方

ブラウザでJobeetのホームページをリフレッシュすることは数日前に投稿されたデータベース内の仕事の内容を変更しません。fixturesにすでにexpiredされた仕事を追加してみましょう。

# data/fixtures/020_jobs.yml
JobeetJob:
  # other jobs
 
  expired_job:
    category_id:  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
    token:        job_expired
    email:        job@example.com

たとえcreated_atカラムがPropelに自動的に値が入れられるとしても、上書きすることができます。fixturesを再読み込みして、ブラウザをリフレッシュすると1番古い仕事は見れなくなっているはずです。

$ symfony propel:data-load

カスタム設定

JobeetJob::save()メソッドでは仕事が利用できなくなる日数の指定がハードコーディングされています。30日にという数字を設定可能できればより良いです。symfonyフレームワークはビルトインされた設定ファイルであるapp.ymlでアプリケーション固有の設定を提供します。このYAMLファイルはしたい設定はなんでも含めることができます。

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

アプリケーションではこれらの設定はグローバルなsfConfigクラスを通して利用可能です。

<?php
sfConfig::get('app_active_days')

後から見るようにsfConfigクラスもsymfonyの設定へアクセスを提供するので、app_が接頭辞として付けられます。
新しい設定をコードに適用してみましょう。

<?php
public function save(PropelPDO $con = null)
{
  if ($this->isNew() && !$this->getExpiresAt())
  {
    $now = $this->getCreatedAt() ? $this->getCreatedAt('U') : time();
    $this->setExpiresAt($now + 86400 * sfConfig::get('app_active_days'));
  }
 
  return parent::save($con);
}

app.ymlファイルはアプリケーションのグローバル設定を集める良い方法です。

リファクタリング

書いたコードが動作してはいますが、まだ完全に正しいものではありません。問題点はどこでしょうか?
Criteriaコードはアクションに含めるべきではありません、それはモデルレイヤにあるべきです。仕事を返すコードであるので、JobeetJobPeerクラスにメソッドを作ってみましょう。

<?php
// lib/model/JobeetJobPeer.php
class JobeetJobPeer extends BaseJobeetJobPeer
{
  static public function getActiveJobs()
  {
    $criteria = new Criteria();
    $criteria->add(self::EXPIRES_AT, time(), Criteria::GREATER_THAN);
 
    return self::doSelect($criteria);
  }
}

メソッドが静的であることに気づいてください。アクションコードは新しいメソッドを下記のように使います。

<?php
public function executeIndex(sfWebRequest $request)
{
  $this->jobs = JobeetJobPeer::getActiveJobs();
}

このリファクタリングには以前のコードよりいくつかの利点があります。

  • 利用可能な仕事を取得するロジックはモデルにあります
  • コントローラのコードはより読みやすくなります
  • getActionJobs()メソッドは再利用できます(例えば、別のアクションで使ったり)
  • モデルコードでユニットテストができます

expires_atカラムで仕事をソートしてみましょう。

<?php
static public function getActiveJobs()
{
  $criteria = new Criteria();
  $criteria->add(self::EXPIRES_AT, time(), Criteria::GREATER_THAN);
  $criteria->addDescendingOrderByColumn(self::EXPIRES_AT);
 
  return self::doSelect($criteria);
}

addDescendingOrderByColumn()メソッドは生成されたSQLにORDER BY句を追加します(addAscendingOrderByColumn()メソッドもあります)。

ホームページでのカテゴリ表示

2日目の必要要件を示します。
"仕事はカテゴリーでまずソートされ、その次に投稿日時でソートされる(新しいものが最初に)"
これまで、仕事のカテゴリーについては考慮していませんでした。必要要件からはホームページでカテゴリーに基づいて表示しなければなりません。まず最初に少なくとも1つの利用可能な仕事から全てのカテゴリーを取得することが必要です。JobeetCategoryPeerクラスを開いてgetWithJobs()メソッドを追加してください。

<?php
// lib/model/JobeetCategoryPeer.php
class JobeetCategoryPeer extends BaseJobeetCategoryPeer
{
  static public function getWithJobs()
  {
    $criteria = new Criteria();
    $criteria->addJoin(self::ID, JobeetJobPeer::CATEGORY_ID);
    $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
    $criteria->setDistinct();
 
    return self::doSelect($criteria);
  }
}

Criteria::addJoin()メソッドは生成されたSQLにJOIN句を追加します。デフォルトではJOINの条件文はWHERE句を追加します。第3引数を
追加してやることでJOIN演算子を変更することも可能です(Criteria::LEFT_JOIN, Criteria::RIGHT_JOIN, Criteria::INNER_JOIN)。
indexアクションを適宜変更します。

<?php
// apps/frontend/modules/job/actions/actions.class.php
public function executeIndex(sfWebRequest $request)
{
  $this->categories = JobeetCategoryPeer::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="">RSS 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><?php echo $job->getLocation() ?></td>
            <td><?php echo link_to($job->getPosition(), 'job_show_user', $job) ?></td>
            <td><?php echo $job->getCompany() ?></td>
          </tr>
        <?php endforeach; ?>
      </table>
    </div>
  <?php endforeach; ?>
</div>

テンプレート内でカテゴリー名を表示するには、echo $categoryを使うことができます。変だと思いませんか? $categoryはオブジェクトなのに、echoはどうやってカテゴリー名を表示させてるのでしょうか? 答えは3日目で全てのモデルクラスに対して定義したマジックメソッド__toString()です。

動作させるために、JobeetCategoryクラスにgetActiveJobs()メソッドを追加します。

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

呼ばれているadd()の中で、Criteria::EQUALはデフォルト値なので第3引数を省略しています。
JobeetJobPeer::getActionJobs()が呼ばれたとき、現在のCriteriaオブジェクトを渡す必要があります。だから、getActiveJobs()は自信のcriteriaと現在のcriteriaをマージする必要があります。Criteriaはオブジェクトであるので、これはかなりシンプルに書けます。

<?php
// lib/model/JobeetJobPeer.php
static public function getActiveJobs(Criteria $criteria = null)
{
  if (is_null($criteria))
  {
    $criteria = new Criteria();
  }
 
  $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
  $criteria->addDescendingOrderByColumn(self::EXPIRES_AT);
 
  return self::doSelect($criteria);
}

結果の制限

ホームページの仕事リストに対し実装すべき1つの要件がまだあります。
"各カテゴリーごとに最新の10件が表示され、得られたカテゴリーに関する全ての仕事のリストへのリンクができます。"
それらはgetActiveJobs()メソッドに単純に追加するだけで十分です。

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

適切なLIMIT句はモデルの中でハードコーディングされていますが、この値を設定可能にすることは良いことです。app.ymlにセットした仕事の最大数をテンプレートに渡すように変更します。

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

動的なFixtures

max_jobs_on_homepageに1より低い値がセットされなければ、違いはわからないでしょう。fixturesを使ってたくさんの仕事を追加することが必要です。そこで、主導で既存の仕事を10〜12回ほどコピペできます、、、がもっと良い方法があります。たとえfixtureファイルであっても重複することは悪です。
symfonyはそれを助けます!symfonyYAMLファイルはファイルの構文解析が始まる前に評価されるPHPのコードを含めることができます。020_jobs.ymlファイルを編集して、下記コードをファイルの最後に追加します。

JobeetJob:
# Starts at the beginning of the line (no whitespace before)
<?php for ($i = 100; $i <= 130; $i++): ?>
  job_<?php echo $i ?>:
    category_id:  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:        job@example.com
 
<?php endfor; ?>

YAMLパーサーはインデントが崩れていると動作しない点に注意してください。YAMLファイルにPHPコードを追加する場合の簡単なTipsを下記に示すので覚えておいてください。

  • 文は常に行の先頭か値に組み込まれている必要があります
  • もし文が行末にあるなら、改行するために("\n")が必要となります

仕事ページの保護

仕事が無効になったら、たとえ仕事ページへのURLを知っていたとしても誰であれアクセスできないようにしなければなりません。無効となった仕事のURLを試します(idパラメータは各環境のデータベースに合わせてください)。

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

仕事ページを表示する代わりに、ユーザを404ページに転送させる必要があります。しかしルートが自動で取得する仕事であるのでどうやればよいでしょうか?
デフォルトではsfPropelRouteはオブジェクトを取得するためにdoSelectOne()メソッドを使いますが、ルートの設定でmethod_for_criteriaオプションを設定してやれば変更することが可能です。

# apps/frontend/config/routing.yml
job_show_user:
  url:     /job/:company_slug/:location_slug/:id/:position_slug
  class:   sfPropelRoute
  options:
    model: JobeetJob
    type:  object
    method_for_criteria: doSelectActive
  param:   { module: job, action: show }
  requirements:
    id: \d+

doSelectActive()メソッドはルートで生成されたCriteriaオブジェクトを受け取ります。

<?php
// lib/model/JobeetJobPeer.php
static public function doSelectActive(Criteria $criteria)
{
  $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
 
  return self::doSelectOne($criteria);
}

今度こそ、無効になった仕事ページにアクセスすると404ページに転送されるようになっているでしょう。

カテゴリーページへのリンク

今度はホームページにカテゴリーページへのリンクを追加してカテゴリーページを作りましょう。
ちょっと待ってください。土曜日だし、まだ1時間も経ってないしそんなに働いてもないです。だから自由時間が十分あるし、自分でこの全ての実装する十分な知識もあります。これは練習問題にしましょう!明日、私たちの実装をチェックしてみてください。