Caution: You are browsing the legacy symfony 1.x part of this website.
Cover of the book Symfony 5: The Fast Track

Symfony 5: The Fast Track is the best book to learn modern Symfony development, from zero to production. +300 pages showcasing Symfony with Docker, APIs, queues & async tasks, Webpack, SPAs, etc.

6日目: モデルの詳細

昨日はすばらしい日でした。 プリティ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_job_list = 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_job_list = JobeetJobPeer::doSelect($criteria);
}

Criteria::add()メソッドは生成されるSQLにWHERE句を追加します。 ここでは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ステートメントを生成します。 しかし時に、Propelが生成したSQLステートメントを見ることは非常に役に立ちます。 たとえば、期待通りに動かないCriteriaをデバッグするときなどです。 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カラムも定義しました。 フィクスチャデータに設定されていないので現在このデータの値は常に空です。

しかしjobが作成されるとき、これを現在の日付の後の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_job_list = 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_job_list = 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()メソッドは生成されたSQLにORDER BY句を追加します(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()メソッドは生成されたSQLにJOIN句を追加します。 デフォルトでは、JOINの条件がWHERE句に追加されます。 3番目の引数を追加すればJOIN演算子を変更することもできます (Criteria::LEFT_JOINCriteria::RIGHT_JOIN、とCriteria::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パーサーはインデントが崩れていると動作しない点に注意してください。 YAMLファイルにPHPコードを追加する場合の簡単なティップスを下記に示すので覚えておいてください:

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