English spoken conference

Symfony 5: The Fast Track

A new book to learn about developing modern Symfony 5 applications.

Support this project

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

6日目: モデルの詳細

1.4 / Propel
Symfony version
1.2
Language ORM

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

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

Propel の Criteria オブジェクト

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

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

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

// apps/frontend/modules/job/actions/actions.class.php
class jobActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
    $this->jobeet_jobs = JobeetJobPeer::doSelect(new Criteria());
  }
 
  // ...
}

アクティブな求人は30日前以内に投稿されたものです。doSelect() メソッドは実行するデータベースへのリクエストを記述する Criteria オブジェクトを受け取ります。上記のコードでは、空の Criteria が渡されます。このことはすべてのレコードがデータベースから検索されることを意味します。

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

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

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

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

手書きで SQL を記述しないので、Propel は異なるデータベース間の違いを管理して、3日目に選んだデータベースエンジンに最適化された SQL 文を生成します。しかし、ときには、たとえば、期待どおりに動かない Criteria をデバッグするときなど、Propel が生成した SQL 文を見ることができれば重宝することがあります。dev 環境では 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

created_at カラムに対して WHERE 句を生成したことがご自身でわかります (WHERE jobeet_job.CREATED_AT > :p1)。

note

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

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

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

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

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

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

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

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

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

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

// 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 を返します。

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

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

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

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

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

# 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:        [email protected]

note

インデントを壊さないようにするために、フィクスチャファイルにコードをコピー&ペーストする際にはご注意ください。expired_job の前には2つのスペースだけしか置かなければなりません。

フィクスチャファイルに追加した求人を見ればわかるように、Propel によって自動的に満たされる場合でも 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 ファイルには任意のコンフィギュレーションを収めることができます:

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

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

sfConfig::get('app_active_days')

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

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

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

リファクタリング

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

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

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

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

public function executeIndex(sfWebRequest $request)
{
  $this->jobeet_jobs = JobeetJobPeer::getActiveJobs();
}

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

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

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

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() メソッドは ORDER BY 句を生成された SQL に追加します (addAscendingOrderByColumn() も存在します)。

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

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

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

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

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

// 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() メソッドは JOIN 句を生成された SQL に追加します。デフォルトでは、JOIN の条件が WHERE 句に追加されます。第3引数を追加すれば JOIN 演算子を変更することもできます (Criteria::LEFT_JOINCriteria::RIGHT_JOINCriteria::INNER_JOIN)。

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

// apps/frontend/modules/job/actions/actions.class.php
public function executeIndex(sfWebRequest $request)
{
  $this->categories = JobeetCategoryPeer::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() マジックメソッドです。

これを動くようにするには、カテゴリオブジェクトにアクティブな求人を返す JobeetCategory クラスに getActiveJobs() メソッドを追加する必要があります:

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

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

// 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 句は 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 コードを格納できます。

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

# 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:        [email protected]
 
<?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 オプションを提供することで変更できます:

# 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+
    sf_method: [GET]

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

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

これで、無効になった求人ページにアクセスすると404エラーページに転送されるようになっているでしょう。

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

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

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

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

また明日

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

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

Good luck!