Caution: You are browsing the legacy 1.x part of this website.
This version of symfony is not maintained anymore. If some of your projects still use this version, consider upgrading.

6日目: モデルの詳細

Jobeet

昨日はすばらしい日でした。プリティ URL の作り方とたくさんのことを自動で行う symfony フレームワークの使い方を学びました。

今日は、あちらこちらのコードを調整して Jobeet の Web サイトを強化します。作業の中で、このチュートリアルの最初の5日のあいだに紹介したすべての機能を詳しく学びます。

Doctrine クエリオブジェクト

2日目の必要要件を下記に示します:

「Jobeet サイトにユーザーが訪れたとき、利用可能な求人情報の一覧が見られる」

しかし現在のところ、利用可能かどうかは関係なくすべての求人情報が表示されます:

// apps/frontend/modules/job/actions/actions.class.php
class jobActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
    $this->jobeet_jobs = Doctrine::getTable('JobeetJob')
      ->createQuery('a')
      ->execute();
  }
 
  // ...
}

アクティブな求人は30日前以内に投稿されたものです。~Doctrine_Query~::execute() メソッドはデータベースへのリクエストを行います。上記のコードにおいて、条件を指定しないので、すべてのレコードがデータベースから検索されることを意味します。

利用可能な求人情報だけが選択されるように変更してみましょう:

public function executeIndex(sfWebRequest $request)
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.created_at > ?', date('Y-m-d H:i:s', time() - 86400 * 30));
 
  $this->jobeet_jobs = $q->execute();
}

Doctrine で生成される SQL のデバッグ

手書きで SQL を記述しないので、Doctrine は異なるデータベース間の違いを管理して、3日目に選んだデータベースエンジンに最適化された SQL 文を生成します。しかし、ときには、たとえば、期待どおりに動かない Criteria をデバッグするときなど、Doctrine が生成した SQL 文を見ることができれば重宝することがあります。dev 環境では log/ ディレクトリのなかでこれらのクエリのログをとります。アプリケーションと環境のすべての組み合わせのログファイルがあります。探しているのは frontend_dev.log という名前のファイルです:

# log/frontend_dev.log
Dec 04 13:58:33 symfony [info] {sfDoctrineLogger} executeQuery : SELECT 
j.id AS j__id, j.category_id AS j__category_id, j.type AS j__type, 
j.company AS j__company, j.logo AS j__logo, j.url AS j__url, 
j.position AS j__position, j.location AS j__location, 
j.description AS j__description, j.how_to_apply AS j__how_to_apply, 
j.token AS j__token, j.is_public AS j__is_public, 
j.is_activated AS j__is_activated, j.email AS j__email, 
j.expires_at AS j__expires_at, j.created_at AS j__created_at, 
j.updated_at AS j__updated_at FROM jobeet_job j 
WHERE j.created_at > ? (2008-11-08 01:13:35)

Doctrine が created_at カラムに対して WHERE 句 (WHERE j.created_at > ?) を用意していることがわかります。

note

クエリの文字列 ? は Doctrine がプリペアードステートメントを生成することを示します。? の実際の値 (上記の例では '2008-11-08 01:13:35') はクエリ実行の間に渡されデータベースエンジンによって適切にエスケープされます。 プリペアードステートメントの利用によって SQL インジェクション攻撃への露出が劇的に減ります。

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

Web デバッグツールバーでの SQL 文

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

上記のコードが動作するとしても、2日目からの要件が考慮されていないので完璧とはほど遠い状態です:

「ユーザーは求人広告の有効期間を30日延長するために戻ることができる」

しかし上記のコードは created_at の値に依存するのと、このカラムは作成時の日付を保存するので上記の要件を満たすことができません。

しかし3日目に記述したデータベーススキーマを覚えているのであれば、expires_at カラムも定義しました。フィクスチャデータに設定されていないので現在このデータの値は常に空です。

しかし求人が作られるとき、これを現在の日付の後の30日に自動的に設定できます。

Doctrine オブジェクトがデータベースにシリアライズされる前に自動的に何かを行う必要があるときに、モデルクラスの save() メソッドをオーバーライドできます:

// lib/model/doctrine/JobeetJob.class.php
class JobeetJob extends BaseJobeetJob
{
  public function save(Doctrine_Connection $conn = null)
  {
    if ($this->isNew() && !$this->getExpiresAt())
    {
      $now = $this->getCreatedAt() ? $this->getDateTimeObject('created_at')->format('U') : time();
      $this->setExpiresAt(date('Y-m-d H:i:s', $now + 86400 * 30));
    }
 
    return parent::save($conn);
  }
 
  // ...
}

isNew() メソッドはオブジェクトがまだデータベースにシリアライズされていないときは true を返し、それ以外は false を返します。

では、アクティブな求人情報を選択するために created_at カラムの代わりに expires_at カラムを利用するアクションを変更してみましょう:

public function executeIndex(sfWebRequest $request)
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.expires_at > ?', date('Y-m-d H:i:s', time()));
 
  $this->jobeet_jobs = $q->execute();
}

将来、expires_at の日付で選択した仕事のみを対象とするためにクエリを制限します。

フィクスチャの高度な使い方

Jobeet のホームページをブラウザでリフレッシュしても、数日前に投稿されたデータベース内の求人内容は変更されません。すでに期限切れした求人情報をフィクスチャに追加してみましょう:

# data/fixtures/jobs.yml
JobeetJob:
  # other jobs
 
  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つのスペースだけしか置かなければなりません。

フィクスチャファイルに追加した求人を見ればわかるように、Doctrine によって自動的に満たされる場合でも created_at カラムの値を定義できます。定義された値はデフォルト値をオーバーライドします。フィクスチャをリロードして古い求人が表示されないことを確認するためにブラウザをリフレッシュします:

$ php symfony doctrine:data-load

created_at の値にもとづき、exprires_at カラムが save() メソッドによって自動的に満たされることを確認するために次のクエリを実行することもできます:

SELECT `position`, `created_at`, `expires_at` FROM `jobeet_job`;

カスタムコンフィギュレーション

JobeetJob::save() メソッドにおいて、期限切れしている求人に関して日数を決め打ちしましたが、30日を設定可能にするほうが望ましいです。symfony フレームワークはアプリケーション固有のコンフィギュレーションのために組み込みの設定ファイル、app.yml ファイルを提供します。この YAML ファイルには任意のコンフィギュレーションを収めることができます:

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

アプリケーションではこれらのコンフィギュレーションはグローバルな sfConfig クラスを通して利用可能です:

sfConfig::get('app_active_days')

後から見るように sfConfig クラスも symfony の設定へアクセス方法を提供し、app_ がプレフィックスとして使われます。

新しい設定をコードに適用してみましょう:

public function save(Doctrine_Connection $conn = null)
{
  if ($this->isNew() && !$this->getExpiresAt())
  {
    $now = $this->getCreatedAt() ? $this->getDateTimeObject('created_at')->format('U') : time();
    $this->setExpiresAt(date('Y-m-d H:i:s', $now + 86400 * sfConfig::get('app_active_days')));
  }
 
  return parent::save($conn);
}

app.yml ファイルはアプリケーションのグローバル設定を集めるよい方法です。最後に、プロジェクト規模での設定が必要な場合、symfony プロジェクトルートの config フォルダで新しい app.yml ファイルを作るだけです。

リファクタリング

書いたコードは動いてはいますが、まだ完全に正しいものではありません。問題はどこにあるのでしょうか?

Doctrine_Query のコードはアクションに所属せず (Controller レイヤー)、Model レイヤーに所属します。MVC モデルにおいて、Model はすべてのビジネスロジックを定義し、Controller はデータを検索するモデルのみを呼び出します。コードは求人のコレクションを返すので、コードを JobeetJobTable クラスに移動させて、getActiveJobs() メソッドを作りましょう:

// lib/model/doctrine/JobeetJobTable.class.php
class JobeetJobTable extends Doctrine_Table
{
  public function getActiveJobs()
  {
    $q = $this->createQuery('j')
      ->where('j.expires_at > ?', date('Y-m-d H:i:s', time()));
 
    return $q->execute();
  }
}

これでアクションにおいて、アクティブな求人データを取得するために新しいメソッドを使うことができます。

public function executeIndex(sfWebRequest $request)
{
  $this->jobeet_jobs = Doctrine_Core::getTable('JobeetJob')->getActiveJobs();
}

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

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

求人を expires_at カラムでソートしてみましょう:

public function getActiveJobs()
{
  $q = $this->createQuery('j')
    ->where('j.expires_at > ?', date('Y-m-d H:i:s', time()))
    ->orderBy('j.expires_at DESC');
 
  return $q->execute();
}

orderBy メソッドは生成された SQL に ORDER BY 句を設定します (addOrderBy() も存在します)。

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

2日目の必要要件を下記に示します:

「求人は、まずカテゴリでソートされ、その次に投稿日時でソートされる (新しいものが最初に)」

これまで、求人のカテゴリについては考慮していませんでした。必要要件からはホームページでカテゴリにもとづいて表示しなければなりません。まず最初に少なくとも1つの利用可能な求人からすべてのカテゴリを取得することが必要です。

JobeetCategoryTable クラスを開き、getWithJobs() メソッドを追加します:

// lib/model/doctrine/JobeetCategoryTable.class.php
class JobeetCategoryTable extends Doctrine_Table
{
  public function getWithJobs()
  {
    $q = $this->createQuery('c')
      ->leftJoin('c.JobeetJobs j')
      ->where('j.expires_at > ?', date('Y-m-d H:i:s', time()));
 
    return $q->execute();
  }
}

index アクションを適宜変更します:

// apps/frontend/modules/job/actions/actions.class.php
public function executeIndex(sfWebRequest $request)
{
  $this->categories = Doctrine_Core::getTable('JobeetCategory')->getWithJobs();
}

テンプレートでは、すべてのカテゴリを渡すように反復し、利用可能な求人を表示する必要があります:

// 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() マジックメソッドです。

これを動くようにするには、getActiveJobs() メソッドを JobeetCategory クラスに追加する必要があります:

// 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 はオブジェクトなので、これらはとてもシンプルです:

// 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() メソッドに追加するだけで十分です:

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

適切な LIMIT 句は Model のなかで決め打ちされていますが、この値を設定可能にすることはよいことです。app.yml にセットした求人の最大件数をテンプレートに渡すように変更します:

<!-- apps/frontend/modules/job/templates/indexSuccess.php --> 
<?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

カテゴリでソートされるホームページ

動的なフィクスチャ

max_jobs_on_homepage に1より低い値がセットされなければ、違いはわからないでしょう。フィクスチャを使ってたくさんの求人を追加することが必要です。そこで、既存の求人を10〜12回ほど手作業でコピー&ペーストすることもできますが、もっとよい方法があります。たとえフィクスチャファイルであっても重複することは悪です。

symfony の YAML ファイルはファイルを解析する直前に評価される PHP コードを格納できます。

jobs.yml フィクスチャを編集して末端に次のコードを追加します:

# Starts at the beginning of the line (no whitespace before)
<?php for ($i = 100; $i <= 130; $i++): ?>
  job_<?php echo $i ?>:
    JobeetCategory: 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 パーサーはインデントが崩れていると動かないことにご注意ください。PHP コードを YAML ファイルを追加する場合の簡単なティップスを下記に示すので覚えておいてください:

  • <?php ?> ステートメントは常に行の先頭か値に組み込まれている必要がある

  • もし <?php ?> ステートメントが行末にあるなら、改行するために (\n) が必要となる

doctrine: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ページに転送させる必要があります。しかしルートが自動的に求人ページを取得するので、これをどうやればよいでしょうか?

# apps/frontend/config/routing.yml
job_show_user:
  url:     /job/:company_slug/:location_slug/:id/:position_slug
  class:   sfDoctrineRoute
  options:
    model: JobeetJob
    type:  object
    method_for_query: retrieveActiveJob
  param:   { module: job, action: show }
  requirements:
    id: \d+
    sf_method: [GET]

retrieveActiveJob() メソッドはルートによってビルドされた Doctrine_Query オブジェクトを受け取ります:

// 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エラーページに転送されるようになっているでしょう。

期限切れした求人用の404エラーページ

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

今度はホームページにカテゴリページへのリンクを追加してカテゴリページを作りましょう。

しかし、少しお待ちください。予定時間はまだ過ぎていないのであまり作業をしてきませんでした。あなたには十分な時間と自分自身でこれをすべて実装するための知識があります!練習してみましょう。明日私たちの実装を確認します。

また明日

ローカルな Jobeet プロジェクトで実装に取り組んでください。symfony 公式サイトで利用できるオンラインの API ドキュメントとすべてのドキュメントを遊んでみてください。

我々の実装と共に明日またお会いしましょう。

Good luck!