モノノフ日記

普通の日記です

Jobeet - 3日目: データモデル

Jobeet - Day 3: The Data Model - Symfony
ようやく少しPHPコードが出てきました。まだ1.2になって新しいなー、と感じるところはありません。Outputzが凄い勢いで枚数カウントされててちょっぴりうれしかったりします。
あと、コメント欄でFabienがDoctrine版のJobeetも提供するつもりだよ、みたいなことを言ってるのが非常に気になります。

前回までのJobeet

みんなテキストエディタを開きたくてうずうずしていて、今日のチュートリアルで覚えたことをPHPで書き留め、開発手法を身につけるでしょう。我々はORMを使ってデータベースと情報をやり取りするデータモデルを定義します。そして、アプリケーションの最初のモジュールを構築します。symfonyはみんなのためにたくさんの仕事をこなしてくれますが、みなさんはたくさんのPHPコードを書くことなく十分に機能的なWebモジュールを使えるようになります。

リレーションモデル

昨日説明したユーザストーリーではプロジェクトの主なオブジェクトについて説明しています。それはjobs, affiliates, categoriesです。下図は対応するエンティティ関係図です。
f:id:Kiske:20081203232332p:image

ストーリーで説明したカラムに加えて、いくつかのテーブルには created_at フィールドも追加しています。symfonyはそのフィールドを見分けて、レコードが生成されたとき現在のシステム時刻を値としてセットします。updated_at フィールドも同様なものです。レコードが更新されるとシステム時刻をセットします。

スキーマ

jobs, affiliates, categoriesを保存するために、当然リレーショナルデータベースが必要となります。
しかしsymfonyのようなオブジェクト指向フレームワークではいつでもオブジェクトを操作したいと考えます。例えば、データベースからレコードを取得するSQL文を書く代わりにオブジェクトを使う方を好みます。
リレーショナルデータベースの情報はオブジェクトモデルとしてマッピングされなければなりません。これはありがたいことにORMツールを使って実現でき、symfonyでは2つのORM(PropelとDoctrine)をバンドルしています。このチュートリアルではPropelを使っていきます。
ORMはテーブルと生成する関連クラスとの関係を説明が必要です。スキーマの記述には2つの方法があります。それは既存のデータベースから作る方法と、手書きで作る方法の2つです。

グラフからデータベースを構築したり(fabFORCE.net)、schema.xmlを直接生成する()ツールもあります。

データベースはまだ存在しておらず、Jobeetのデータベースは不可視でありたいと考えています。さぁ、空の config/schema.yml ファイルを編集してschemaファイルを作ってみましょう!

# config/schema.yml
propel:
  jobeet_category:
    id:           ~
    name:         { type: varchar(255), required: true }
 
  jobeet_job:
    id:           ~
    category_id:  { type: integer, foreignTable: jobeet_category, foreignReference: id, required: true }
    type:         { type: varchar(255) }
    company:      { type: varchar(255), required: true }
    logo:         { type: varchar(255) }
    url:          { type: varchar(255) }
    position:     { type: varchar(255), required: true }
    location:     { type: varchar(255), required: true }
    description:  { type: longvarchar, required: true }
    how_to_apply: { type: longvarchar, required: true }
    token:        { type: varchar(255), required: true, index: unique }
    is_public:    { type: boolean, required: true, default: 1 }
    is_activated: { type: boolean, required: true, default: 0 }
    email:        { type: varchar(255), required: true }
    expires_at:   { type: timestamp, required: true }
    created_at:   ~
    updated_at:   ~
 
  jobeet_affiliate:
    id:           ~
    url:          { type: varchar(255), required: true }
    email:        { type: varchar(255), required: true, index: unique }
    token:        { type: varchar(255), required: true }
    is_active:    { type: boolean, required: true, default: 0 }
    created_at:   ~
 
  jobeet_job_affiliate:
    job_id:       { type: integer, foreignTable: jobeet_job, foreignReference: id, required: true, primaryKey: true, onDelete: cascade }
    affiliate_id: { type: integer, foreignTable: jobeet_affiliate, foreignReference: id, required: true, primaryKey: true, onDelete: cascade }

もしSQL文でテーブル作ることにしているのなら、propel:build-schema タスクを実行して一致する schema.ymlファイルを生成してください。

スキーマYAMLフォーマット内でエンティティ関係図の構成に直接変換されます。

schema.yml ファイルは全てのテーブルとカラムの説明を含んでいます。各カラムは下記の情報をつけて記述されます。

type
カラムの種類(boolean, tinyint, smallint, integer, bigint, double, float, real, decimal, char, varchar(size), longvarchar, date, time, timestamp, blob, clob)
required
trueをセットするとNOT NULLが設定されます
index
trueをセットするとINDEXが張られて、uniqueをセットするとUNIQUE INDEXが張られます

~ がセットされたカラム(id, craeted_at, updated_at)は、symfonyが適切な設定を推測して設定してくれます。(idはPRIMARY KEYと推測し、 created_atやupdated_atはTIMESTAMPと推測)

ondelete属性は外部キーのON DELETEの動作を定義し、PropelではCASCADE、SETNULL、RESTRICTをサポートします。例えばjobレコードが削除されると、jobeet_job_affiliateテーブルの関連する全てのレコードがデータベースによって削除されます。もしデータベースがこの機能に対応していなければPropelによって削除されます。

データベース

symfonyはPDOが対応するデータベース全てをサポートしています(MySQL, PostgreSQL, SQLite, Oracle, MSSQL, ...)。PDOはPHPにバンドルされてるデータベース抽象レイヤです。
このチュートリアルではMySQLを使いましょう。

$ mysqladmin5 -uroot create jobeet
$ mysql5 -uroot
GRANT ALL ON jobeet.* TO sfuser@localhost IDENTIFIED BY "sfpass";

symfonyにデータベースの設定を教えます。

$ symfony configure:database "mysql:host=localhost;dbname=jobeet" sfuser sfpass

configure:database タスクは3つの引数を持ちます。それはPDO DSNとDBユーザ名とDBパスワードです。もし開発サーバなどでパスワードを設定していないのなら3つ目に引数は省略可能です。

configure:database タスクは config/database.yml ファイルに結果を保存します。タスクを使う代わりに手動で編集してもOKです。

ORM

schema.ymlに書いたデータベースの説明のおかげで、PDOのビルトイン関数を利用してテーブルを生成するためのSQL文を作れます。

$ symfony propel:build-sql

propel:build-sql タスクはdata/sqlディレクトリに設定されているデータベースエンジンに最適化されたSQL文を生成します。
実際にデータベース上にテーブルを生成するには、propel:insert-sql タスクを実行する必要があります。

$ symfony propel:insert-sql

このタスクはテーブルを再生成する前に現在のテーブルを削除するので、確認ダイアログが出てきます。--no-confirmationオプションを使えば、その確認ダイアログを無視できるのでバッチに組みこむときは便利です。

$ symfony propel:insert-sql --no-confirmation

ORMはテーブルレコードをオブジェクトにマッピングしたPHPクラスも生成します。

$ symfony propel:build-model

propel:build-model タスクはlib/modelディレクトリにデータベースと情報をやりとりするためのPHPファイルを生成します。
生成されたファイルを見ることで、Propelが1テーブルから4つのクラスを生成することにおそらく気付くでしょう。jobeet_jobテーブルを例に挙げます。

JobeetJob
このクラスのオブジェクトはjobeet_jobテーブルの1つのレコードを意味します。デフォルトでは空です。
BaseJobeetJob
JobeetJobクラスの親クラスです。propel:build-modelが実行されるたびに上書きされます。だからカスタマイズは全てJobeetJobクラスで行わなければなりません。
JobeetJobPeer
スタティックメソッドが定義されており、そのほとんどがJobeetJobオブジェクトを返り値です。デフォルトでは空です。
BaseJobeetJobPeer
JobeetJobPeerクラスの親クラスです。propel:build-modelが実行されるたびに上書きされます。だからカスタマイズは全てJobeetJobPeerクラスで行わなければなりません。

レコードのカラム値はアクセサ(get*()メソッド)やミューテータ(set*()メソッド)を使ってモデルオブジェクトで操作されます。

<?php
$job = new JobeetJob();
$job->setPosition('Web developer');
$job->save();

echo $job->getPosition();

$job->delete(); 

オブジェクトと一緒に直接リンクする外部キーを定義できます。

<?php
$category = new JobeetCategory();
$category->setName('Programming');
 
$job = new JobeetJob();
$job->setCategory($category);

propel:build-all タスクはこの章で行ったタスクを一括してやってくれるショートカットです。また、formsやvalidatorsのJobeetモデルオブジェクトの生成もします。

$ symfony propel:build-all

今日の最後にアクションにおけるvalidatorを見ることができます。formsについては10日目にもっと詳しく説明する予定です。

propel:build-all-load タスクはpropel:build-allを実行した後にpropel:data-loadを実行するショートカットです。

あとで見ることになりますが、symfonyPHPクラスをオートロードします。このことはコード内でrequireを使う必要がないということを意味してます。symfonyが開発者のために自動でやってくれるのはたくさんの事柄の1つです。しかし1点よくないところもあります。それはsymfonyに新しいクラスを追加したときはキャッシュをクリアする必要があることです。propel:build-modelはたくさんのクラスを生成するのでキャッシュをクリアしましょう。

$ symfony cache:clear

初期データ

データベースにテーブルが作成されました、しかしデータがありません。Webアプリケーションでは3種類のデータがあります。

初期データ
アプリケーションを動作させるのに必要なデータ。例えば、Jobeetだとカテゴリーが必要となります。もしカテゴリーが無ければ誰も仕事を投稿できなくなります。backendにログインできるadminユーザが必要になります。
テストデータ
アプリケーションのテストに必要です。開発者にとって、ストーリー通りにJobeetが動作するのを確実にするためにテストを書きます。自動化されたテストを書くのが1番良い方法です。テストを動かすたびにテストデータでデータベースをきれいにする必要があります。
ユーザデータ
アプリケーションが普通の状態でユーザによって作られたデータ

symfonyがデータベースにテーブルを作るたびに全てのデータは失くなります。初期データをデータベースに登録させるにはPHPで生成するか、mysqlからSQLを実行させます。しかしありふれた要求として、symfony内で行うのが1番良い方法です。data/fixturesディレクトリにYAMLファイルを生成し、propel:data-load タスクを使ってデータベースへロードします。

# data/fixtures/010_categories.yml
JobeetCategory:
  design:        { name: Design }
  programming:   { name: Programming }
  manager:       { name: Manager }
  administrator: { name: Administrator }
 
# data/fixtures/020_jobs.yml
JobeetJob:
  job_sensio_labs:
    category_id:  programming
    type:         full-time
    company:      Sensio Labs
    logo:         /uploads/jobs/sensio_labs.png
    url:          http://www.sensiolabs.com/
    position:     Web Developer
    location:     Paris, France
    description:  |
      You've already developed websites with symfony and you want to work
      with Open-Source technologies. You have a minimum of 3 years
      experience in web development with PHP or Java and you wish to
      participate to development of Web 2.0 sites using the best
      frameworks available.
    how_to_apply: |
      Send your resume to fabien.potencier [at] sensio.com
    is_public:    true
    is_activated: true
    token:        job_sensio_labs
    email:        job@example.com
    expires_at:   2010-10-10
 
  job_extreme_sensio:
    category_id:  design
    type:         part-time
    company:      Extreme Sensio
    logo:         /uploads/jobs/extreme_sensio.png
    url:          http://www.extreme-sensio.com/
    position:     Web Designer
    location:     Paris, France
    description:  |
      Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do
      eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
      enim ad minim veniam, quis nostrud exercitation ullamco laboris
      nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
      in reprehenderit in.
 
      Voluptate velit esse cillum dolore eu fugiat nulla pariatur.
      Excepteur sint occaecat cupidatat non proident, sunt in culpa
      qui officia deserunt mollit anim id est laborum.
    how_to_apply: |
      Send your resume to fabien.potencier [at] sensio.com
    is_public:    true
    is_activated: true
    token:        job_extreme_sensio
    email:        job@example.com
    expires_at:   2010-10-10

fixtureファイルはYAMLで記述され、ユニーク名がラベルされたモデルオブジェクトを定義します。このラベルはプライマリキーを定義すること無しに関連オブジェクトをリンクするのに大いに役に立ちます。例えば、job_sensio_labsのカテゴリーはprogrammimngで、それは"Programming"カテゴリーから得ることができます。
fixtureファイルは1つのオブジェクトかいくつかのオブジェクトを含みます。

ファイル名のprefixに数字がついてるのに気づいてください。これはデータロードの順番をコントロールする簡単な方法です。より最近のプロジェクトでいくつかの新しいfixtureファイルを挿入することになったなら、現在使ってる番号の間で番号付けをしてやるだけで良いです。

fixtureファイルでは全てのカラム値を定義する必要はありません。もしそうでないなら、symfonyはデータベーススキーマで定義されてるデフォルト値を使います。symfonyはデータベースへデータをロードさせるのにPropelを使うので、ビルトイン関数を使ったり動作しているモデルクラスにカスタム関数を追加してもよいです。
データベースに初期データをロードするには単にpropel:data-loadタスクを実行するだけです。

$ symfony propel:data-load

ブラウザ上での動作確認

たくさんのCLIを使いますが、あんまり面白いものではありません。とりわけWebプロジェクトにとっては。データベースと情報をやり取りするWebページを作ることができます。
仕事のリストの表示や編集、削除の方法を見てみましょう。1日目で説明しましたが、symfonyはアプリケーションで構成されます。各アプリケーションはモジュールから構成されます。モジュールはアプリケーションの要素を記述したPHPコードやユーザが使うモデルオブジェクトから構成されます。
symfonyは自動で基本要素を操作できるモジュールを作ることができます。

symfony propel:generate-module --with-show --non-verbose-templates frontend job JobeetJob

仕事の編集を試したいなら、symfonyはカテゴリーのテキスト表示が必要となるのでExceptionが出るでしょう。PHPでオブジェクトの表現は__toString()というマジックメソッドを使って定義できます。カテゴリーレコードのテキスト表示はJobeetCategoryモデルクラスで定義します。

<?php
// lib/model/JobeetCategory.php
class JobeetCategory extends BaseJobeetCategory
{
  public function __toString()
  {
    return $this->getName();
  }
}

これでカテゴリーのテキスト表現をするたびに__toString()メソッドが呼ばれてカテゴリー名が返されます。他のモデルクラスでも必要となるので全てのモデルに対して__toString()を定義しましょう。

<?php
// lib/model/JobeetJob.php
class JobeetJob extends BaseJobeetJob
{
  public function __toString()
  {
    return sprintf('%s at %s (%s)', $this->getPosition(), $this->getCompany(), $this->getLocation());
  }
}
 
// lib/model/JobeetAffiliate.php
class JobeetAffiliate extends BaseJobeetAffiliate
{
  public function __toString()
  {
    return $this->getUrl();
  }
}

これで仕事の作成と編集が使えるようになりました。必須項目を空にして保存しようとすると、symfonyはデータベーススキーマから基本バリデーションルールを作ります。