6日目: モデルの詳細
昨日はすばらしい日でした。プリティ URL の作り方とたくさんのことを自動で行う symfony フレームワークの使い方を学びました。
今日は、あちらこちらのコードを調整して Jobeet の Web サイトを強化します。作業の中で、このチュートリアルの最初の5日のあいだに紹介したすべての機能を詳しく学びます。
Propel の Criteria オブジェクト
Doctrine クエリオブジェクト
2日目の必要要件を下記に示します:
「Jobeet サイトにユーザーが訪れたとき、利用可能な求人情報の一覧が見られる」
しかし現在のところ、利用可能かどうかは関係なくすべての求人情報が表示されます:
[php]
// apps/frontend/modules/job/actions/actions.class.php
class jobActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
  // ...
}
doSelect() メソッドは実行するデータベースへのリクエストを記述する ~Criteria~ オブジェクトを受け取ります。上記のコードでは、空の Criteria が渡されます。このことはすべてのレコードがデータベースから検索されることを意味します。
~Doctrine_Query~::execute() メソッドはデータベースへのリクエストを行います。上記のコードにおいて、条件を指定しないので、すべてのレコードがデータベースから検索されることを意味します。
利用可能な求人情報だけが選択されるように変更してみましょう:
[php]
public function executeIndex(sfWebRequest $request)
{
  $this->jobeet_jobs = JobeetJobPeer::doSelect($criteria);
  $this->jobeet_jobs = $q->execute();
}
Criteria::add() メソッドは WHERE 句を生成される SQL に 追加します。ここでは30日よりも古くない求人情報のみを選択するために基準を制限します。add() メソッドはたくさんの異なる比較演算子を受け取ります。次のものがよく使われます:
- 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
##ORM## で生成される SQL のデバッグ
手書きで SQL を記述しないので、##ORM## は異なるデータベース間の違いを管理して、3日目に選んだデータベースエンジンに最適化された SQL 文を生成します。しかし、ときには、たとえば、期待どおりに動かない Criteria を~デバッグ~するときなど、##ORM## が生成した SQL 文を見ることができれば重宝することがあります。dev ~環境~では log/ ディレクトリのなかでこれらのクエリのログをとります。アプリケーションと環境のすべての組み合わせのログファイルがあります。探しているのは frontend_dev.log という名前のファイルです:
# log/frontend_dev.log
jobeet_job WHERE jobeetjob.CREATEDAT>: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
created_at カラムに対して WHERE 句を生成したことがご自身でわかります (WHERE jobeet_job.CREATED_AT > :p1)。
NOTE クエリの
:p1文字列は Propel が~プリペアードステートメント~を生成することを示します。:p1の実際の値 (上記の例では '2008-11-06 15:47:12') はクエリ実行のあいだに渡されデータベースによって適切にエスケープされます。プリペアードステートメントの利用によって ~SQL インジェクション~攻撃への露出が劇的に減ります。
Doctrine が created_at カラムに対して WHERE 句 (WHERE j.created_at > ?) を用意していることがわかります。
NOTE クエリの文字列
?は Doctrine が~プリペアードステートメント~を生成することを示します。?の実際の値 (上記の例では '2008-11-08 01:13:35') はクエリ実行の間に渡されデータベースエンジンによって適切にエスケープされます。 プリペアードステートメントの利用によって ~SQL インジェクション~攻撃への露出が劇的に減ります。
このことはよいことです。しかし変更点のテストのため毎回ブラウザ、IDE、ログファイルの間を行ったりきたりするのは少し面倒です。symfony の Web デバッグツールバーのおかげで、必要なすべての情報がブラウザ上で快適に利用できます:

オブジェクトの~シリアライズ~
上記のコードが動作するとしても、2日目からの要件が考慮されていないので完璧とはほど遠い状態です:
「ユーザーは求人広告の有効期間を30日延長するために戻ることができる」
しかし上記のコードは created_at の値に依存するのと、このカラムは作成時の日付を保存するので上記の要件を満たすことができません。
しかし3日目に記述したデータベーススキーマを覚えているのであれば、expires_at カラムも定義しました。フィクスチャデータに設定されていないので現在このデータの値は常に空です。 
しかし求人が作られるとき、これを現在の日付の後の30日に自動的に設定できます。
ORM## オブジェクトがデータベースにシリアライズされる前に自動的に何かを行う必要があるときに、モデルクラスの save() メソッドをオーバーライドできます:
    return parent::save($con);
  }
  // ...
}
    return parent::save($conn);
  }
  // ...
}
isNew() メソッドはオブジェクトがまだデータベースにシリアライズされていないときは true を返し、それ以外は false を返します。
では、アクティブな求人情報を選択するために created_at カラムの代わりに expires_at カラムを利用するアクションを変更してみましょう:
[php]
public function executeIndex(sfWebRequest $request)
{
  $this->jobeet_jobs = JobeetJobPeer::doSelect($criteria);
  $this->jobeet_jobs = $q->execute();
}
将来、expires_at の日付で選択した仕事のみを対象とするためにクエリを制限します。
フィクスチャの高度な使い方
Jobeet のホームページをブラウザでリフレッシュしても、数日前に投稿されたデータベース内の求人内容は変更されません。すでに期限切れした求人情報をフィクスチャに追加してみましょう:
  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
  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:          job@example.com
NOTE インデントを壊さないようにするために、~フィクスチャ~ファイルにコードをコピー&ペーストする際にはご注意ください。
expired_jobの前には2つのスペースだけしか置かなければなりません。
フィクスチャファイルに追加した求人を見ればわかるように、##ORM## によって自動的に満たされる場合でも created_at カラムの値を定義できます。定義された値はデフォルト値をオーバーライドします。フィクスチャをリロードして古い求人が表示されないことを確認するためにブラウザをリフレッシュします:
$ php symfony propel:data-load
created_at の値にもとづき、exprires_at カラムが save() メソッドによって自動的に満たされることを確認するために次のクエリを実行することもできます:
SELECT `position`, `created_at`, `expires_at` FROM `jobeet_job`;
カスタムコンフィギュレーション
JobeetJob::save() メソッドにおいて、期限切れしている求人に関して日数を決め打ちしましたが、30日を設定可能にするほうが望ましいです。symfony フレームワークは~アプリケーション~固有の~コンフィギュレーション~のために組み込みの設定ファイル、~app.yml~ ファイルを提供します。この YAML ファイルには任意のコンフィギュレーションを収めることができます:
[yml]
# apps/frontend/config/app.yml
all:
  active_days: 30
アプリケーションではこれらのコンフィギュレーションはグローバルな ~sfConfig~ クラスを通して利用可能です:
[php]
sfConfig::get('app_active_days')
後から見るように sfConfig クラスも symfony の設定へアクセス方法を提供し、app_ がプレフィックスとして使われます。
新しい設定をコードに適用してみましょう:
  return parent::save($con);
}
  return parent::save($conn);
}
~app.yml~ ファイルはアプリケーションの~グローバル設定~を集めるよい方法です。最後に、~プロジェクト規模での設定|グローバルコンフィギュレーション~が必要な場合、symfony プロジェクトルートの config フォルダで新しい app.yml ファイルを作るだけです。 
リファクタリング
書いたコードは動いてはいますが、まだ完全に正しいものではありません。問題はどこにあるのでしょうか?
Criteria のコードはアクションに所属せず (Controller レイヤー)、Model レイヤーに所属します。~MVC~ モデルにおいて、モデルはすべての~ビジネスロジック~を定義し、Controller はデータを検索するモデルのみを呼び出します。コードは求人のコレクションを返すので、コードを JobeetJobPeer クラスに移動させて getActiveJobs() メソッドを作りましょう:
Doctrine_Query のコードはアクションに所属せず (Controller レイヤー)、Model レイヤーに所属します。~MVC~ モデルにおいて、Model はすべての~ビジネスロジック~を定義し、Controller はデータを検索するモデルのみを呼び出します。コードは求人のコレクションを返すので、コードを JobeetJobTable クラスに移動させて、getActiveJobs() メソッドを作りましょう:
    return self::doSelect($criteria);
  }
}
    return $q->execute();
  }
}
これでアクションにおいて、アクティブな求人データを取得するために新しいメソッドを使うことができます。
[php]
public function executeIndex(sfWebRequest $request)
{
この~リファクタリング~には以前のコードよりいくつかの利点があります。:
- 利用可能な仕事を取得するロジックは Model にある
- Controller のコードはより読みやすくなる
- getActionJobs()メソッドは再利用できる (たとえば別のアクションで使う)
- Model コードでユニットテストができる
求人を expires_at カラムでソートしてみましょう:
[php]
  return self::doSelect($criteria);
}
  return $q->execute();
}
addDescendingOrderByColumn() メソッドは ORDER BY 句を生成された SQL に追加します (addAscendingOrderByColumn() も存在します)。
orderBy メソッドは生成された SQL に ORDER BY 句を設定します (addOrderBy() も存在します)。
ホームページでのカテゴリ表示
2日目の必要要件を下記に示します:
「求人は、まずカテゴリでソートされ、その次に投稿日時でソートされる (新しいものが最初に)」
これまで、求人のカテゴリについては考慮していませんでした。必要要件からはホームページでカテゴリにもとづいて表示しなければなりません。まず最初に少なくとも1つの利用可能な求人からすべてのカテゴリを取得することが必要です。
JobeetCategoryPeer クラスを開き、getWithJobs() メソッドを追加します:
JobeetCategoryTable クラスを開き、getWithJobs() メソッドを追加します:
    return self::doSelect($criteria);
  }
}
Criteria::addJoin() メソッドは ~JOIN~ 句を生成された SQL に追加します。デフォルトでは、JOIN の条件が WHERE 句に追加されます。第3引数を追加すれば JOIN 演算子を変更することもできます (Criteria::LEFT_JOIN、Criteria::RIGHT_JOIN と Criteria::INNER_JOIN)。
    return $q->execute();
  }
}
index アクションを適宜変更します:
[php]
// apps/frontend/modules/job/actions/actions.class.php
public function executeIndex(sfWebRequest $request)
{
テンプレートでは、すべてのカテゴリを渡すように反復し、利用可能な求人を表示する必要があります:
[php]
// apps/frontend/modules/job/templates/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日目ですべての Model クラスに対して定義した__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引数を省略しました。
JobeetCategory::getActiveJobs() メソッドは渡されたカテゴリ用にアクティブな求人を検索するために JobeetJobPeer::getActiveJobs() メソッドを使います。
JobeetJobPeer::getActiveJobs() を呼び出すとき、カテゴリを提供することで条件をさらに制限したい場合を考えます。カテゴリオブジェクトを渡す代わりに、Criteria オブジェクトを渡すことに決めました。これが一般的な条件をカプセル化するための最善の方法だからです。
getActiveJobs() は独自の基準で 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);
}
getActiveJobs() メソッドを JobeetCategory クラスに追加する必要があります:
[php]
// 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() を呼び出すとき、カテゴリを提供することでさらに条件を制限することを考えます。カテゴリオブジェクトを渡すことで、Doctrine_Query オブジェクトを渡すことに決めました。これが一般的な条件をカプセル化するための最善の方法だからです。
getActiveJobs() はこの Doctrine_Query オブジェクトを独自のクエリでマージする必要があります。Doctrine_Query はオブジェクトなので、これらはとてもシンプルです:
[php]
// 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();
}
結果の制限
ホームページの仕事リストのなかに実装すべき1つの要件がまだあります:
「各カテゴリごとに最新の10件を表示し、得られたカテゴリに関するすべての求人リストへのリンクを表示する」
これらは単に getActiveJobs() メソッドに追加するだけで十分です:
  return JobeetJobPeer::getActiveJobs($criteria);
}
  return Doctrine_Core::getTable('JobeetJob')->getActiveJobs($q);
}
適切な ~LIMIT~ 句は Model のなかで決め打ちされていますが、この値を設定可能にすることはよいことです。app.yml にセットした求人の最大件数をテンプレートに渡すように変更します:
[php]
<!-- apps/frontend/modules/job/templates/indexSuccess.php --> 
<?php foreach ($category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')) as $i => $job): ?>
加えて、app.yml 新しい設定に追加します。
[yml]
all:
  active_days:          30
  max_jobs_on_homepage: 10

動的なフィクスチャ
max_jobs_on_homepage に1より低い値がセットされなければ、違いはわからないでしょう。~フィクスチャ~を使ってたくさんの求人を追加することが必要です。そこで、既存の求人を10〜12回ほど手作業でコピー&ペーストすることもできますが、もっとよい方法があります。たとえフィクスチャファイルであっても重複することは悪です。
symfony の ~YAML~ ファイルはファイルを解析する直前に評価される PHP コードを格納できます。
020_jobs.yml フィクスチャファイルを編集して末端に次のコードを追加します:
jobs.yml フィクスチャを編集して末端に次のコードを追加します:
[php]
# Starts at the beginning of the line (no whitespace before)
<?php for ($i = 100; $i <= 130; $i++): ?>
  job_<?php echo $i ?>:
<?php endfor ?>
YAML パーサーは~インデント|コードのフォーマッティング~が崩れていると動かないことにご注意ください。PHP コードを YAML ファイルを追加する場合の簡単なティップスを下記に示すので覚えておいてください:
- <?php ?>ステートメントは常に行の先頭か値に組み込まれている必要がある
- もし - <?php ?>ステートメントが行末にあるなら、改行するために (- \n) が必要となる
propel:data-load タスクでフィクスチャファイルをリロードして、ホームページで Programming カテゴリに対して10件の求人だけが表示されるか見ます。次のスクリーンショットにおいて、画像を小さくするために求人の最大数を5件に変更しました:

求人ページの保護
求人が期限切れであれば、URL がわかっていても、アクセスできないようにすべきです。期限切れの求人用の URL を試してください (id を実際のデータベースの id に置き換えてください - SELECT id, token FROM jobeet_job WHERE expires_at < NOW()):
/frontend_dev.php/job/sensio-labs/paris-france/ID/web-developer-expired
求人ページを表示する代わりに、ユーザーを404ページに転送させる必要があります。しかしルートが自動的に求人ページを取得するので、これをどうやればよいでしょうか?
sfPropelRoute~ は標準の doSelectOne() メソッドを使います。オブジェクトを検索するには、~ルート~設定の ~method_for_criteria~ オプションを提供することで変更できます:
[yml]
# apps/frontend/config/routing.yml
job_show_user:
  url:     /job/:company_slug/:location_slug/:id/:position_slug
  class:   sfPropelRoute
  options:
    model: JobeetJob
    type:  object
doSelectActive() メソッドはルートによってビルドされた Criteria オブジェクトを受け取ります:
[php]
// lib/model/JobeetJobPeer.php
class JobeetJobPeer extends BaseJobeetJobPeer
{
  static public function doSelectActive(Criteria $criteria)
  {
    $criteria->add(JobeetJobPeer::EXPIRES_AT, time(),
     ➥ Criteria::GREATER_THAN);
    return self::doSelectOne($criteria);
  }
  // ...
}
retrieveActiveJob() メソッドはルートによってビルドされた Doctrine_Query オブジェクトを受け取ります:
[php]
// 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エラーページに転送されるようになっているでしょう。

カテゴリページへのリンク
今度はホームページにカテゴリページへのリンクを追加してカテゴリページを作りましょう。
しかし、少しお待ちください。予定時間はまだ過ぎていないのであまり作業をしてきませんでした。あなたには十分な時間と自分自身でこれをすべて実装するための知識があります!練習してみましょう。明日私たちの実装を確認します。
また明日
ローカルな Jobeet プロジェクトで実装に取り組んでください。symfony 公式サイトで利用できるオンラインの ~API~ ドキュメントとすべての~ドキュメント~を遊んでみてください。
我々の実装と共に明日またお会いしましょう。
Good luck!
ORM