昨日はすばらしい日でした。プリティ 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 デバッグツールバーのおかげで、必要なすべての情報がブラウザ上で快適に利用できます:
オブジェクトのシリアライズ
上記のコードが動作するとしても、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エラーページに転送されるようになっているでしょう。
カテゴリページへのリンク
今度はホームページにカテゴリページへのリンクを追加してカテゴリページを作りましょう。
しかし、少しお待ちください。予定時間はまだ過ぎていないのであまり作業をしてきませんでした。あなたには十分な時間と自分自身でこれをすべて実装するための知識があります!練習してみましょう。明日私たちの実装を確認します。
また明日
ローカルな Jobeet プロジェクトで実装に取り組んでください。symfony 公式サイトで利用できるオンラインの API ドキュメントとすべてのドキュメントを遊んでみてください。
我々の実装と共に明日またお会いしましょう。
Good luck!
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.