「チュートリアルで作成したブログにタグ機能を実装するチュートリアル」をcakePHP2.0でやってみた。

元ネタはこれ。チュートリアルで作成したブログにタグ機能を実装するチュートリアル – 「最果て」の支部

cakePHPを最近触りだしたのですが、巷にcakePHP1系の情報があふれているのでなかなか大変な今日この頃です。

とりあえず、cakePHP2.0のブログチュートリアルにタグ機能を実装してみました。

やっていることは元ネタと変わらないので、そちらを読みつつやっていければと思います。

テーブルの作成

タグ機能みたいな、複数の値と複数の値を関連づけるような機能(この場合だと複数の記事が一つのタグに所属する&複数のタグが一つの記事に所属する)は、一筋縄ではいかないようです。投稿を管理するテーブルにタグを管理するフィールドを作って、タグのテーブルにもその逆を作って、そこにカンマ区切りとかjsonとかで・・・・とか、どう考えても無茶が過ぎるし、SQLで対処できなくなりますよね。

なので、タグと投稿を関連づけるための別のテーブルを用意します。WordPressだと、term_relationshipsっていうテーブルが有るのですが、それと同じ事をやろうというわけ。

それをcakePHPではhasAndBelongsToMany通称HABTMと言う機能で実装できるようです。

なので、まずその二つのテーブルを作成します。

[sql]
CREATE TABLE `tags` (
`id` int(10) unsigned NOT NULL auto_increment,
`tag` varchar(100) default NULL,
PRIMARY KEY (`id`)
)
[/sql]

[sql]
CREATE TABLE `posts_tags` (
`post_id` int(10) unsigned NOT NULL default ‘0’,
`tag_id` int(10) unsigned NOT NULL default ‘0’,
PRIMARY KEY (`post_id`,`tag_id`)
)
[/sql]

HABTMのテーブル名は、関連づけるテーブル1_テーブル2、フィールド名も、テーブル1の単数形_idで作成するようにと規約にあるようなので、その方がベターだと思われます。

モデルの作成

Tag.phpをModelディレクトリ内に作成します。
この中にタグの追加、編集などを行うメソッド(parseTags)を定義します。これをコントローラーで呼ぶ訳ですね。

[php]
<?php
class Tag extends AppModel {
public $name = ‘Tag’;

public $hasAndBelongsToMany = array(
‘Post’ => array(
‘className’ => ‘Post’,
‘joinTable’ => ‘posts_tags’,
‘foreignKey’ => ‘tag_id’,
‘associationForeignKey’ => ‘post_id’
)
);

public function parseTags($tagString) {
$ids = array();

$tags = explode(‘,’,trim($tagString));

foreach ($tags as $tag):
if(!empty($tag)){
//DBからタグに対応する列を取得
$tag = trim($tag);
$this->unbindModel(array(‘hasAndBelongsToMany’ => array(‘Post’)));
$tagRow = $this->findByTag($tag);

if(is_array($tagRow)){
//タグが登録済の処理
if(in_array($tagRow[‘Tag’][‘id’], $ids)){
continue;
}
$ids[] = $tagRow[‘Tag’][‘id’];
}else {
$newTag[‘Tag’][‘tag’] = $tag;
$newTag[‘Tag’][‘id’] = "";
$this->save($newTag);
$ids[] = $this->getLastInsertID();
}
}

endforeach;

return $ids;
}
}

[/php]

$hasAndBelongsToMany繋ぐテーブルを定義します。
結合とかはこれだけで勝手にやってくれる模様。

次いで、Post.php。
$hasAndBelongsToManyを定義するのは一緒。
Tag.phpでは書いてあった、’foreignKey’と’associationForeignKey’が書いてません。ただ、デフォルト値でforeignKeyは現在のモデル名_id,associationForteignKeyは外部モデル名_idになるようですので問題ないようです。
ここで、findAllByTagsというデータベースからあるタグがついた記事のデータを持ってくるメソッドと、それの数を数えるfindCountByTagsというメソッドの二つを用意します。

[php]
class Post extends AppModel {
public $name = ‘Post’;

public $validate = array(
‘title’ => array(
‘rule’ => ‘notEmpty’
),
‘body’ => array(
‘rule’ => ‘notEmpty’
)
);

public $hasAndBelongsToMany = array(
‘Tag’ => array(
‘className’ => ‘Tag’,
‘order’ => ‘tag’
)
);

public function findCountByTags($tags = array(), $criteria = null) {
if(count($tags) <= 0){
return 0;
}
if(!empty($criteria)) {
$criteria = ‘AND’.$criteria;
}

$prefix = $this->tablePrefix;
$count = $this->query(
"SELECT COUNT(‘Post.id’) AS count FROM
(SELECT Post.id, COUNT(DISTINCT tags.tag) AS uniques
FROM {$prefix}posts Post, {$prefix}posts_tags posts_tags, {$prefix}tags tags
WHERE Post.id = posts_tags.Post_id
AND tags.id = posts_tags.tag_id
AND tags.tag IN (‘".implode("’, ‘", $tags)."’) $criteria
GROUP BY posts_tags.Post_id
HAVING uniques = ".count($tags).") x" );

return $count[0][0][‘count’];
}

public function findAllByTags($tags = array(), $limit = 50, $page = 1, $criteria = null){
if(count($tags) <= 0){
return 0;
}
if(!empty($criteria)) {
$criteria = ‘AND’.$criteria;
}

$prefix = $this->tablePrefix;
$offset = $limit * ($page-1);
$posts = $this->query(
"SELECT
Post.id,
Post.title,
Post.created,
COUNT(DISTINCT tags.tag) AS uniques
FROM
{$prefix}posts Post,
{$prefix}posts_tags posts_tags,
{$prefix}tags tags
WHERE Post.id = posts_tags.Post_id
AND tags.id = posts_tags.tag_id
AND tags.tag IN (‘".implode("’, ‘", $tags)."’) $criteria
GROUP BY posts_tags.Post_id
HAVING uniques = ‘".count($tags)."’
ORDER BY Post.created DESC
LIMIT $offset, $limit"
);

return $posts;
}
}
[/php]

コントローラーの作成、編集

PostController.phpでは、
まず、add,editにタグを追加、編集する機能を追加。元記事では、$this->dataとなっていますが、cakePHP2.0の場合は$this->request->dataとしないとエラーになります。なりました。

そして、tagアクションの追加。ここで、先ほどPostモデルで作ったメソッドが使われるわけですね。

[php]
<?php
class PostsController extends AppController {
public $name = ‘Posts’;
public $uses = array(‘Post’,’Tag’);
public $helper = array(‘Html’,’Form’);
public $components = array(‘Session’);

public function index() {
//全てのデータの読み出し
$posts = $this->Post->find(‘all’);
$this->set(‘posts’,$posts);
}

public function view($id = null) {
//単一投稿の表示
$this->Post->id = $id;
$post = $this->Post->read();
$this->set(‘post’,$post);
}

public function add() {
if($this->request->is(‘post’)){//$_POSTの判定

//タグのパース
if(!empty($this->request->data[‘Post’][‘Tags’])){
$this->request->data[‘Tag’][‘Tag’] = $this->Post->Tag->parseTags($this->data[‘Post’][‘Tags’]);
}

if($this->Post->save($this->request->data)){
$this->Session->setFlash(‘投稿が保存されました’);
$this->redirect(array(‘action’=>’index’));//indexへリダイレクト
}
else {
$this->Session->setFlash(‘投稿が保存できませんでした’);
}
}
}

public function edit($id = null) {
$this->Post->id = $id;
if($this->request->is(‘get’)){
//読み出し
$this->request->data = $this->Post->read();//データの取得

//タグの取得
if(count($this->request->data[‘Tag’])){
$tags = ”;
foreach ($this->request->data[‘Tag’] as $tag) {//一行ずつ取り出す
$tags .= $tag[‘tag’].",";
}
$this->request->data[‘Post’][‘Tags’] = substr($tags, 0 ,-1);//最後のカンマを取り除いてViewにデータを投げる
}
}
else {

if(!empty($this->data[‘Post’][‘Tags’])){
$this->request->data[‘Tag’][‘Tag’] = $this->Post->Tag->parseTags($this->data[‘Post’][‘Tags’]);
}

if($this->Post->save($this->request->data)){
$this->Session->setFlash(‘投稿を更新しました’);
$this->redirect(array(‘action’=> ‘index’));
}else{
$this->Session->setFlash(‘更新できませんでした’);
}
}
}

public function delete($id) {
if($this->request->is(‘get’)){
//GETならエラー
throw new MethodNotAllowedException();
}

if($this->Post->delete($id)){
$this->Session->setFlash(‘ID:’.$id.’の投稿を削除しました’);
$this->redirect(array(‘action’=>’index’));
}
}

public function tag($tag = null) {
$tags = array();
App::uses(‘Sanitize’, ‘Utility’);
$this->Sanitize = new Sanitize;

if(isset($this->params[‘pass’])){

foreach($this->params[‘pass’] as $tag):
$this->Sanitize->paranoid($tag, array(‘ ‘));
$tags[] = $tag;
endforeach;

}
$paging[‘url’] = ‘/posts/tag’. implode(‘/’, $tags);
$paging[‘total’] = $this->Post->findCountByTags($tags);
if($paging[‘total’] > 0){
$posts = $this->Post->findAllByTags($tags);
$this->set(‘posts’,$this->Post->findAllByTags($tags));
$this->render(‘index’);
}
else {
//タグの記事が無い場合の処理。記事が見つかりませんでしたみたいな。
//exit;
}
}

}

[/php]

Viewの編集

とりあえず、add.ctpとedit.ctpにタグ用のテキストボックスを追加。
[php]
echo $this->Form->input(‘Tags’, array(‘size’ => ’40’));
[/php]

edit.ctpはこんな感じ。add.ctpは省略。
[php]
<h1>Edit Post</h1>
<?php
echo $this->Form->create(‘Post’, array(‘action’ => ‘edit’));
echo $this->Form->input(‘title’);
echo $this->Form->input(‘body’, array(‘rows’ => ‘3’));
echo $this->Form->input(‘Tags’, array(‘size’ => ’40’));
echo $this->Form->input(‘id’, array(‘type’ => ‘hidden’));
echo $this->Form->end(‘Save Post’);
?>
[/php]

ここまででちゃんと動くはず!

タグクラウドの作成

ここではタグクラウドをTagsController.phpに作成です。タグクラウド(゚⊿゚)イラネって人は別に作らないでいいと思います。
[php]

class TagsController extends AppController {

public $name = "Tags";

public function tagcloudbox(){
$prefix = $this->Tag->tablePrefix;
$tagsData = $this->Tag->query(
"SELECT tags. * , count(posts_tags.tag_id) PostCount
FROM {$prefix}tags tags
LEFT JOIN {$prefix}posts_tags posts_tags ON tags.id = posts_tags.tag_id
GROUP BY tags.id
ORDER BY tags.tag"
);

$this->set(‘tags’, $tagsData);

$PostCounts = array();

if(is_array($tagsData) && count($tagsData) > 0) {
foreach($tagsData as $tagDetails):
$PostCounts[] = $tagDetails[0][‘PostCount’];
endforeach;
}
else {
$PostCounts[] = 0;
}

$maxQuantity = max($PostCounts);
$minQuantity = min($PostCounts);

$spread = $maxQuantity – $minQuantity;

if($spread == 0){
$spread = 1;
}

$this->set(‘spread’, $spread);
$this->set(‘minQuantity’, $minQuantity);
$this->set(‘browsing’, ‘Tags’);

}
}
[/php]

んで、View/Tags/tagcloudbox.ctpを作成
[php]
<div id="sidecontent">
<h2>Tag</h2>
<?php
$step = (30-10) /$spread;

foreach($tags as $tag):
$fontSize = 10 + ($tag[0][‘PostCount’] – $minQuantity) * $step;
endforeach;

echo $this->Html->Link($tag[‘tags’][‘tag’], array(‘action’ => ‘tag’,’controller’ => ‘posts’,$tag[‘tags’][‘tag’]), array(‘title’ => $tag[0][‘PostCount’].’Photos’,’rel’ => ‘tag’, ‘style’ => ‘font-size:’.$fontSize.’px’)).’ ‘;

?>
</div>
[/php]

ここでは普通にViewにしてありますが、エレメントにしてあげて、他のViewから呼んであげるとかの使い方が良さそうです。

とりあえず何とかcakePHP2.0で実装できました。
ちょっとできる人になった気分!笑
まぁ、タグって結構需要はあると思うので、こんな感じで実装できました!ってだけのエントリーなんですけどね。

posts/tag/tag1/tag2/tag3
みたいな絞り込みもできるようです。

ただ、この元ネタのcheesecake-photoblogのライセンスはGPLv2ですので、当然このエントリーのコードもGPLv2です。使う際はご注意下さい。