HABTMのタグの絞込み機能のお話
CakePHPで、あるテーブルに対してHABTMで持たせたタグを絞り込んで表示させる機能を、散々苦労してやっと実装できたのでメモしておきます。*1
説明のためにCakePHPのブログチュートリアルのモデルをさらに簡略化して、それを元に説明してみます。
Model
規約に従っていれば必要の無いプロパティも記事に汎用性を持たせるために書いておきますね。
記事テーブル
テーブル:posts
モデル:Post
フィールド:id, article, created, modified*2
class Post extends AppModel { var $name = "Post"; var $useTable = "posts"; // 規約に従えばいらない // 規約に従えば$hasAndBelongsToMany = array("Tag");でOK var $hasAndBelongsToMany = array( "Tag" => array( "className" => "Tag", "joinTable" => "posts_tags", "with" => "PostsTag", "foreignKey" => "post_id", "associationForeignKey" => "tag_id", "uniq" => true, ) ); }
タグテーブル
テーブル:tags
モデル:Tag
フィールド:id, name, created, modified
class Tag extends AppModel { var $name = "Tag"; var $useTable = "tags"; // 規約に従えばいらない // 規約に従えば$hasAndBelongsToMany = array("Post");でOK var $hasAndBelongsToMany = array( "Post" => array( "className" => "Post", "joinTable" => "posts_tags", "with" => "PostsTag", "foreignKey" => "tag_id", "associationForeignKey" => "post_id", "uniq" => true, ) ); }
記事とタグをつなぐHABTMテーブル
テーブル:posts_tags
モデル:PostsTag
フィールド:id, post_id, tag_id, created, modified
class PostTag extends AppModel { var $name = "PostTag"; var $useTable = "posts_tags"; // 規約に従えばいらない var $belongsTo = array("Post", "Tag"); // 規約に従ったことにして省略 }
以上、3テーブルを使って説明します。
データの保存については説明しなくても検索すればいくらでも情報が出てくると思いますので省きますね。もし保存方法がわからないという方は
hasAndBelongsToMany (HABTM) :: 関連: モデルを結びつける :: モデル :: CakePHPによる開発 :: マニュアル :: 1.2 Collection :: The Cookbook
に詳しく書かれていますので参考にしてください。
cakephp habtm – Google 検索
googleでもものすごい件数引っかかりますので大丈夫ですよね?
データを持ってくる考え方
僕のSQLの知識が乏しいことが問題なんですが、どうしたら複数のタグを指定してすべて満たしたものだけを取得できるのか?これは相当悩みました。
例えば、タグID1,2,3をすべて指定してある記事データを取得したいとしますよね。僕が試してみた順に説明していくと…
「PostsTag.tag_id IN (1,2,3)」で指定してやったらどうか?と思ったわけですよ。(もちろん駄目)
$tags = array(1,2,3); // tagsテーブルに登録されているタグIDを指定 $cond = array("PostsTag.tag_id" => $tags); // conditionsで配列を指定してやると複数の場合条件をINで指定してくれる $result = $this->Post->PostsTag->find("all", array("conditions" => $cond));
とやると、当然のことながらタグ1,2,3のどれかひとつでも持っているものはすべて取得してしまうわけですよね。ORで1,2,3を指定するようなものですね。ちょっと考えればわかるだろうという感じですけども…。これでは目的を満たせませんね。
次に、じゃあANDでつなげばいいじゃない!と思ったわけです。(これも全然駄目)
$tags = array(1,2,3); $cond = array( "PostsTag.tag_id" => array( $tags ) ); $result = $this->Post->PostsTag->find("all", array("conditions" => $cond));
結果は…惨敗。そりゃそうですよね。今度はひとつのフィールドに複数の値を持つデータを探そうとするわけですから見つかるはずありません(笑)PostsTagテーブルの1レコードでtag_idが「1かつ2かつ3」を満たすデータなんてありませんよね。
単純にPostsTagのデータに条件指定するだけでは絶対に取得できないんだということにここで気付きました。ではどうしたらいいのか?
僕が考え付いたその方法とは…
PostsTag.post_idでGROUP BYをかけて、まずは記事単位のデータ*3 にしてあげます。
そしてWHERE条件*4 には「PostsTag.tag_id IN (1,2,3)」を指定します。1番最初に試した例と同じですが、指定した3つのタグのうち一つでも持っていれば取得しますよね。問題はここからです。
指定したタグの数を数えて、Postが指定したタグの数のタグを持つものだけを取得すれば…いけるんじゃないですか?
ためしにクエリを書いてみましょう。
SELECT * FROM posts_tags pt LEFT JOIN posts p ON p.id = pt.post_id LEFT JOIN tags t ON t.id = pt.tag_id WHERE pt.tag_id IN (1,2,3) GROUP BY pt.post_id HAVING COUNT(pt.tag_id) = 3 /* 今回はタグを3つ指定しているので3ね */
「WHERE pt.tag_id IN (1,2,3)」の条件指定をしているので、その時点で取得してくる記事が持っているタグの数(COUNT(pt.tag_id))の最大値は3ですよね。「HAVINGでCOUNT(pt.tag_id) = 最大値」で、すべてのタグを持っているレコードのみが取得できるというわけです。
それじゃCakePHPのコントローラでどう書けばいいのか解説しましょう。
Controller
class PostsController extends AppController { var $name = "Posts"; var $uses = array( "Post", "PostsTags", // これいらないかも。 "Tag" // これはたぶんいらない ); // タグを指定してデータを取得 function index() { $tagIds = array(1, 2, 3); // 実際はViewのフォームから取得 $cond = null; if (count($tagIds) > 0) { $cond = array("PostsTag.tag_id" => $tagIds); } // ここでGROUP BY と HAVING で指定タグの数を指定する $group = array("PostsTag.post_id having count(PostsTag.tag_id) = " . count($tagIds)); // データ取得 $result = $this->Post->PostsTag->find("all", array("conditions" => $cond, "group" => $group)); } }
こんな感じで、CakePHPのアソシエーションを生かしたまま目的のデータを取得することができました。僕にとってはとても手ごわい相手でしたが、きっとできる方ならすぐにできちゃうんだろうなぁ。こういうときにはこういう方法というのが経験的にわかってくるんじゃないかと、そういう意味では今回かなり勉強になったなぁって思います。
ちなみに、実際に僕が作ったものには続きがあって…
- PostsTagには登録したUserIDを持たせてタグの削除はタグ付けしたユーザしかできないようにした
- タグの絞込みはjQueryを使ってクリックでオンオフさせ、オンオフのタイミングでajaxでリストを更新させた*5
- タグの絞込み部分はタグクラウドにする(予定)
- このシステムのためのブックマークレットも作ろうと思っている
- 実はブログではない別のものを作った(作っている)
- 社内システムで使うための仕組みだが、こなれてきたら一般公開したいと思っている
こんな感じになってます。
いつか一般公開するかもしれませんので、そのときはよろしくお願いしますね 😉
あと、jQueryで画面変遷無しにタグの絞りこみを実装したあたりは、js素人なもんでかなり苦労したので、これもそのうち記事を書きたいと思ってます。
相変わらず言い回しが下手でわかりにくい記事かと思いますが、今回の内容は他所で見つけられなかったものですし、かなりいい記事になったんじゃないかと我ながら思ってます 🙂
感想などありましたらどしどしコメントなりチャットなりで反応いただけたら幸いです。また、「ここはもっとこうした方がいい」などアドバイスがありましたら、ぜひともお聞かせいただければありがたいです。
それでは
コメント
タグ機能を実装したくて、いろいろと検索していてこちらの記事を発見しました。
>>PostsTagには登録したUserIDを持たせてタグの削除はタグ付けしたユーザしかできないようにした
当方もこれを実装したいのですが、どのように保存すればよいのでしょうか?
$data = array(
‘Post’ => array(‘id’ => {Post.id}),
‘Tag’ => array(‘Tag’ => array({Tag.id},…))
);
$this->Post->save($data);
こんなかんじのとき、user_idはどこにいれればよいのでしょうか?
よろしければ教えていただけないでしょうか?
こんにちは、薫さん。コメントありがとうございます。
この部分説明すると非常に長くなりそうなので省いたのですが…^^;簡単にご説明だけ。
僕の場合、テキストボックスにカンマ区切りで入力された文字列をタグとして登録する仕様なので、フォームから受け取ったタグ部分のテキストを、Tagsモデルに追加したparseTags($tagString, $userId)メソッドを通して処理しています。
タグテキストはexplodeでタグに分割してループするわけですが、このときにTagsに登録されていない新規のタグがあればここで登録してしまいます。
$newTag[‘Tag’][‘id’] = “”;
$newTag[‘Tag’][‘tag’] = $tag;
$this->save($newTag);
このときに$this->getInsertID();で追加したタグのIDを保持しておきます。タグがすでに存在している場合は$this->findByTag($tag);でIDを保持します。
これでPostsTagに保存するデータがつくれますので、
$data[] = array(
“tag_id” => $tagId,
“user_data” => $userId, // 引数ね
);
あとはこのデータをreturnしてController側で$this->data[Tag][Tag] = $return、$this->Posts->save($this->data);で保存完了です。
全然簡単に説明できてないですね…
もしわからなければ僕がオンラインのときにチャットで質問してください。もし僕に余裕があればリアルタイムでご質問にお答えできると思います。(今月一杯は忙しいのでちょっと難しいかもだけど。)それでは、chao
なるほど!
$data[‘Tag’][‘Tag’]
に入れればよかったんですね!
ちょっと試してみたところ、うまくいきました!
お忙しい中、こんな見ず知らずの者に丁寧な回答をしていただき感激です!
ありがとうございます!
お役に立ててよかったです 🙂