yiicによって生成されたPostモデルクラスは、おもに三つの変更が必要です。
rules()メソッドは、モデル属性の検証ルールを指定します。
relations()メソッドは、オブジェクト同士の関連を指定します。
safeAttributes()メソッドはどの属性が一括代入可能であるかを指定します。(主にユーザの入力値をモデルに代入する際に使われます)
訳者注:ここで「属性」とは、モデルクラスのプロパティ(メンバ変数)のことですが、 テーブル列と対応するものを、特に区別するために「属性」という単語が使われています。
モデルには属性のリストが存在します。 各属性は対応するデータベーステーブルの列に対応します。 属性はクラスのメンバ変数として明示的に宣言することができます。 あるいは、宣言なしで黙示的に使うこともできます。
rules()メソッドの変更
まずはじめに、検証ルールを指定します。
ユーザ入力値をデータベースにセーブするには、それらが正しいことを確認する必要があります。
たとえば、status属性は0,1,2のいずれかでなくてはなりません。
yiicツールはまた各モデルの検証ルールを自動で生成します。
しかし、自動生成されたルールはテーブルの列情報に基づいたもので、適切でない可能性があります。
要求分析に基づいて、rules()メソッドを以下のように修正します。
public function rules()
{
return array(
array('title, content, status', 'required'),
array('title', 'length', 'max'=>128),
array('status', 'in', 'range'=>array(0, 1, 2)),
array('tags', 'match', 'pattern'=>'/^[\w\s,]+$/',
'message'=>'Tags can only contain word characters.'),
);
}
上記の例では、title、content、statusは入力必須です。
titleは128文字までです。
statusは0(下書き),1(公開),2(非公開)のいずれかの値です。
tagsは文字とカンマのみ入力可能です。
ほかのすべての属性(例:id, createTimeなど)は検証されません。
なぜならこれらの値はユーザ入力には存在しないからです。
これらの変更を行った後、再度記事作成のページを表示し、新しい検証ルールが有効になっているかどうか、確認できます。
検証ルールは、モデルインスタンスのvalidate()かsave()を呼んだときに使われます。 検証ルール指定のより詳しい情報については、the Guideを参照してください。
safeAttributes()メソッドの変更
次にsafeAttributes()を変更し、一括代入で属性に値を代入できるようにします。
ユーザの入力値をモデルインスタンスに渡す際、コードを簡単にするために、以下のような一括代入をよく利用します。
$post->attributes=$_POST['Post'];
一括代入を使わないと、以下のような長いコードを書く必要があります。
$post->title=$_POST['Post']['title'];
$post->content=$_POST['Post']['content'];
......(属性の数だけ続く)
一括代入は大変便利ですが、悪意のあるユーザが本来読み書きされるべきでない属性値を上書きしようとするかもしれないという潜在的な危険があります。
たとえば、編集中の記事idはユーザの入力値によって変更されるべきではありません。
そのような危険を防ぐため、safeAttributes()を以下のように変更します。
これは、title、content、status、tagsの四つの属性のみが一括代入されうることを表します。
public function safeAttributes()
{
return array('title', 'content', 'status', 'tags');
}
ヒント:どの属性がセーフリストに含まれるべきかというのは、ユーザが情報を入力するHTMLフォームを見ることで用意に区別できます。 フォームに表示されるモデル属性は、一括代入しても安全であると考えていいでしょう。 というのも、ユーザからの入力は常に何らかの検証を受けるべきだからです。
relations()メソッドを変更する
最後にrelations()メソッドを変更し、記事に関連するオブジェクトを指定します。
relations()メソッドでオブジェクトの関連を宣言することで、
強力なリレーショナルアクティブレコード (RAR)の機能を活用することができます。
複雑なSQL JOIN文を書くことなく、記事の著者やコメントなどの情報をにアクセスできます。
relations()メソッドを以下のように変更します。
public function relations()
{
return array(
'author'=>array(self::BELONGS_TO, 'User', 'authorId'),
'comments'=>array(self::HAS_MANY, 'Comment', 'postId',
'order'=>'??.createTime'),
'tagFilter'=>array(self::MANY_MANY, 'Tag', 'PostTag(postId, tagId)',
'together'=>true,
'joinType'=>'INNER JOIN',
'condition'=>'??.name=:tag'),
);
}
上に示した内容は、以下のような関係を宣言しています。
Userです。
記事と著者のリレーションは、記事のauthorId属性に基づいて成立します。
Commentクラスであらわされます。
記事とコメントのリレーションは、コメントのpostId属性に基づいて成立します。
コメントは作成日時でソートされます。
tagFilterリレーションは少し複雑です。
PostテーブルとTagテーブルを明示的にジョインするために使われ、
指定されたタグ名の行のみを選択します。
このリレーションをどう使うかは、記事の表示機能を実装する際に説明します。
上記のリレーション宣言によって、以下のように記事の著者とコメントに容易にアクセスすることができます。
$author=$post->author;
echo $author->username;
$comments=$post->comments;
foreach($comments as $comment)
echo $comment->content;
リレーションの宣言と使い方についてのより詳しい説明は、the Guideを参照してください。
記事のステータスはデータベースでは整数として保存されるため、ユーザにステータスを表示するには理解しやすいテキスト表現を提供する必要があります。
そのため、Postモデルを以下のように変更します。
class Post extends CActiveRecord
{
const STATUS_DRAFT=0;
const STATUS_PUBLISHED=1;
const STATUS_ARCHIVED=2;
......
public function getStatusOptions()
{
return array(
self::STATUS_DRAFT=>'Draft',
self::STATUS_PUBLISHED=>'Published',
self::STATUS_ARCHIVED=>'Archived',
);
}
public function getStatusText()
{
$options=$this->statusOptions;
return isset($options[$this->status]) ? $options[$this->status]
: "unknown ({$this->status})";
}
}
ここではクラス定数を持ちいてステータスコードを定義しています。
これらの定数はコードをよりメンテナンスしやすくします。
またgetStatusOptions()メソッドは、ステータスの数値とテキスト表現の対応を返します。
そして最後に、getStatusText()メソッドで、記事のステータスをテキスト表現で返します。
Postモデルの準備ができたので、PostConrollerのアクションとビューを少し変更する必要があります。
このセクションでは、まずCRUD操作のアクセスコントロールを変更します。次にcreateとupdateの実装を変更します。
最後にプレビュー機能を追加します。
まずはじめにやることは、access controlの変更です。 なぜならyiicによって生成されたコードはわれわれの要求に合わないからです
/wwwroot/blog/protected/controllers/PostController.phpのaccessRules()メソッドを以下のように変更します。
public function accessRules()
{
return array(
array('allow', // allow all users to perform 'list' and 'show' actions
'actions'=>array('list', 'show'),
'users'=>array('*'),
),
array('allow', // allow authenticated users to perform any action
'users'=>array('@'),
),
array('deny', // deny all users
'users'=>array('*'),
),
);
}
上記のルールは、すべてのユーザがlistとshowアクションにアクセスでき、
認証済みのユーザがすべてのアクションにアクセスできるよう宣言しています。
その他のシナリオにおいてはユーザのアクセスは拒否されます。
これらのルールは上から順に評価されることに注意してください。
最初のルールが現在のコンテキストに合致した場合、そこでアクセス可否が決定されます。
たとえば、現在のユーザがシステムオーナで、記事作成ページを表示しようとした場合、2番目のルールが適用されユーザのアクセスが許可されます。
createとupdateを変更する
createとupdateの二つの操作はとてもよく似ています。
どちらもHTMLフォームを表示してユーザからの入力を受け取り、入力値を検証し、データベースに保存します。
一番の違いは、updateにおいてはあらかじめデータベースに登録されたデータが読み出され、フォームにセットされるということです。
このためyiicツールはパーシャルビュー /wwwroot/blog/protected/views/post/_form.php を生成し、
それをcreateとupdateのビューに埋め込んでHTMLを表示します。
まずはじめに_form.phpファイルが必要なHTMLフォーム要素を表示するように変更します。
必要なのはtitle、content、statusの三要素です。
最初の二つにはテキストフィールドを、ステータスにはドロップダウンリストを表示します。
ドロップダウンリストの項目は記事のステータスリストです。
statusOptions); ?>
上の例では、Post::model()->statusOptionsのかわりに、Post::model()->getStatusOptions()をつかって
ステータスリストを得ることもできます。
なぜそうしないのかというと、Postのプロパティにはメソッド経由でアクセスすることができるからです。
次にPostクラスを変更し、いくつかの属性を自動で設定するようにします。(例:createTimeauthorId)
beforeValidate()メソッドを以下のようにオーバーライドします。
protected function beforeValidate($on)
{
$parser=new CMarkdownParser;
$this->contentDisplay=$parser->safeTransform($this->content);
if($this->isNewRecord)
{
$this->createTime=$this->updateTime=time();
$this->authorId=Yii::app()->user->id;
}
else
$this->updateTime=time();
return true;
}
このメソッドでは、CMarkdownParserを使って、
入力された内容をMarkdown formatからHTMLに変換し、
その結果をcontentDisplayに保存しています。
これによって記事が表示されるときに何度も繰り返してフォーマット変換が起きないようにしています。
新規投稿の場合、createTime属性とauthorId属性をセットします。
更新の場合はupdateTimeを現在時刻にセットします。
このメソッドはモデルのvalidate()メソッドかsave()メソッドが呼ばれる際に、自動的に実行されることに注意してください。
記事のタグをTagテーブルに保存したいので、以下のようなメソッドをPostクラスに追加します。
このメソッドは記事がデータベースにセーブされた後に自動的に実行されます。
protected function afterSave()
{
if(!$this->isNewRecord)
$this->dbConnection->createCommand(
'DELETE FROM PostTag WHERE postId='.$this->id)->execute();
foreach($this->getTagArray() as $name)
{
if(($tag=Tag::model()->findByAttributes(array('name'=>$name)))===null)
{
$tag=new Tag(array('name'=>$name));
$tag->save();
}
$this->dbConnection->createCommand(
"INSERT INTO PostTag (postId, tagId) VALUES ({$this->id},{$tag->id})")->execute();
}
}
public function getTagArray()
{
// break tag string into a set of tags
return array_unique(
preg_split('/\s*,\s*/',trim($this->tags),-1,PREG_SPLIT_NO_EMPTY)
);
}
上記のコードでは、まずPostTagテーブルの関連する行をすべて削除します。
次に新しいタグをTagテーブルに追加し、その参照をPostTagテーブルに追加します。
このロジックは多少複雑です。
ActiveRecordを使う代わりに、
じかにSQL文を書いてそれを実行しています。
ビジネスロジックを、コントローラではなく、モデルに記述するのはよいプラクティスです。
これまでの変更のほかに、プレビュー機能を追加して、セーブの前に内容を確認できるようにします。
まず_form.phpビューにpreviewボタンとプレビュー表示を追加します。
プレビューは、プレビューボタンが押され、エラーがない場合のみ表示されます。
'previewPost')); ?>
......
hasErrors()): ?>
...display preview of $post here...
次にPostControllerのactionCreate()とactionUpdate()を、プレビュー可能なように変更します。
以下に新しいactionCreate()のコードを示します。これはactionUpdate()のものと大変よく似ています。
public function actionCreate()
{
$post=new Post;
if(isset($_POST['Post']))
{
$post->attributes=$_POST['Post'];
if(isset($_POST['previewPost']))
$post->validate();
else if(isset($_POST['submitPost']) && $post->save())
$this->redirect(array('show','id'=>$post->id));
}
$this->render('create',array('post'=>$post));
}
上記のコードでは、プレビューボタンがクリックされたとき、$post->validate()メソッドを呼んでユーザの入力値を検証します。
サブミットボタンが押された場合は、$post->save()メソッドを呼んでデータの保存を試みます。この場合暗黙のうちに検証が行われます。
セーブが成功なら、ユーザを新しく作成された記事のページへとりダイレクトします。
このブログアプリケーションでは、記事は一覧表示と単独表示のいずれかで表示されます。
一覧表示はlist操作として、単独表示はshow操作として、それぞれ実装されます。
このセクションでは、この二つの操作を変更して、要求仕様を満たすようにします。
show操作の変更
show操作はactionShow()メソッドで実装されます。
表示されるビューファイルは/wwwroot/blog/protected/views/post/show.phpです。
以下に関連コードを示します。
public function actionShow()
{
$this->render('show',array(
'post'=>$this->loadPost(),
));
}
private $_post;
protected function loadPost($id=null)
{
if($this->_post===null)
{
if($id!==null || isset($_GET['id']))
$this->_post=Post::model()->findbyPk($id!==null ? $id : $_GET['id']);
if($this->_post===null || Yii::app()->user->isGuest &&
$this->_post->status!=Post::STATUS_PUBLISHED)
throw new CHttpException(500,'The requested post does not exist.');
}
return $this->_post;
}
主な変更はloadPost()メソッドです。このメソッドでは、
GETパラメータのidに基づいてPostテーブルに問い合わせを行い、
記事が見つからないか公開されていない場合、500 HTTP Errorを投げます。
記事が見つかった場合、その内容をshowビューに渡して表示します。
ヒント:YiiはHTTP例外を取得します。例外はCHttpExceptionのインスタンスです。 例外はあらかじめ用意されたテンプレートを使って、エラー画面に表示されます。 これらのテンプレートはアプリケーションごとに変更できます。 詳細はこのチュートリアルの最後にあります。
showビューでの変更は、記事表示のフォーマットとスタイルの調整です。ここでは詳しく述べません。
list 操作の変更
show操作と同じく、list操作も二ヶ所変更します。
コントローラのactionList()メソッドと /wwwroot/blog/protected/views/post/list.php ビューファイルです。
特定のタグに関連付けられた記事の一覧表示機能を追加することが主な内容です。
以下にactionList()メソッドの変更点を示します。
public function actionList()
{
$criteria=new CDbCriteria;
$criteria->condition='status='.Post::STATUS_PUBLISHED;
$criteria->order='createTime DESC';
$withOption=array('author');
if(!empty($_GET['tag']))
{
$withOption['tagFilter']['params'][':tag']=$_GET['tag'];
$postCount=Post::model()->with($withOption)->count($criteria);
}
else
$postCount=Post::model()->count($criteria);
$pages=new CPagination($postCount);
$pages->applyLimit($criteria);
$posts=Post::model()->with($withOption)->findAll($criteria);
$this->render('list',array(
'posts'=>$posts,
'pages'=>$pages,
));
}
上記の例ではまずクエリ基準をつくり、公開された記事が作成日時の降順に並ぶようにします。
次にこの基準を満たす記事の総数を得ます。
記事の総数はページ処理コンポーネントで、ページ数を計算するのに利用されます。
最後に記事データを取得し、listビューに渡します。
GETパラメータにtagがある場合、tagFilterを使ってクエリを実行していることに注意してください。
tagFilterをクエリに含めることで、指定されたタグを持つ記事を単一のSQL JOIN文で取得することを確実にします。
この呼び出しがないと、Yiiはクエリを二つに分割し、正確でない結果を返します。
list ビューには二つの変数が渡されます。$posts と $pages です。
$posts は記事の一覧表示に使われます。
$pages はページ処理の情報を含みます。(例:トータルページ数、現在ページ)
list ビューでは、ページ処理ウィジェット
によって、自動で記事が複数のページに分けて表示されます。
記事管理は主に管理ビューで記事のリストを見ることと、記事の削除を行うことです。 それぞれadmin操作とdelete操作で実現されます。 yiicで生成されたコードにあまり手を加える必要はありません。 以下ではこの二つの操作がどのように実装されているかを解説します。
admin操作はすべての記事を表示します。(公開されているものも、未公開のものも含めて) このビューは複数の列によるソートとページ処理をサポートします。 actionAdmin()の内容は以下のとおりです。
public function actionAdmin()
{
$criteria=new CDbCriteria;
$pages=new CPagination(Post::model()->count());
$pages->applyLimit($criteria);
$sort=new CSort('Post');
$sort->defaultOrder='status ASC, createTime DESC';
$sort->applyOrder($criteria);
$posts=Post::model()->findAll($criteria);
$this->render('admin',array(
'posts'=>$posts,
'pages'=>$pages,
'sort'=>$sort,
));
}
上記のコードは actionList() ととてもよく似ています。 主な違いはCSortオブジェクトを使ってソート情報を表していることです。 (例:どの列をどの方向へソートするか) CSortオブジェクトはadminビューで、テーブルのヘッダセルのリンクを生成するのに使われています。 リンクをクリックすることで、ページが再読み込みされ、ソートが行われます。
以下にadminビューのコードを示します。
Manage Posts
link('status'); ?>
link('title'); ?>
link('createTime'); ?>
link('updateTime'); ?>
$post): ?>
statusText); ?>
title),
array('show','id'=>$post->id)); ?>
createTime); ?>
updateTime); ?>
widget('CLinkPager',array('pages'=>$pages)); ?>
このコードは非常にわかりやすいものです。 記事リストを最後まで繰り返し読み出して、それらを表に表示します。 テーブルのヘッダセルではCSortオブジェクトを使って ソートのためのリンクを生成しています。 最後に、CLinkPagerウィジェットを組み込んで、 ベージ処理のボタンを表示しています。
ヒント:テキストを表示する際に、HTMLエンティティをエンコードするCHtml::encode() を使っています。これによって、cross-site scripting attackを防ぎます。
show操作で記事が表示された場合、システムオーナに対してdeleteリンクを表示します。 このリンクをクリックすることで、記事の削除を行えます。 記事の削除はサーバサイドのデータが変更されるので、POSTリクエストを使って削除を実行します。 したがって、以下のようにdeleteボタンを生成する必要があります。
array('post/delete','id'=>$post->id),
'confirm'=>"Are you sure to delete this post?",
)); ?>
CHtml::linkButton()メソッドは普通のボタンのように見えるリンクボタンを生成します。 リンクをクリックすることでHTMLフォームが送信されます。 ここではフォームの送信先URLを array('post/delete','id'=>$post->id) と指定します。 このブログアプリケーションでは、生成されたURLは /blog/index.php?r=post/delete&id=1 のようになります。 これは、PostController の delete アクションを参照します。 また、ユーザに削除を再確認するために、クリックした際に確認ダイアログが表示されるようになっています。
delete 操作についてのコードは読めばわかるので、ここで解説はしません。
public function actionDelete()
{
if(Yii::app()->request->isPostRequest)
{
// we only allow deletion via POST request
$this->loadPost()->delete();
$this->redirect(array('list'));
}
else
throw new CHttpException(500,'Invalid request...');
}