CakePHPのSearch pluginでタグの絞込み検索を作る(AND検索)
一昨日にOR検索のほうは修正したばかりですが、CakeDCのsearch pluginの記事が少ないので1個置いときますね。CakePHP Advent Calendar 2010 8日目 - kanonjiの日記のコードに機能追加しました。色々な検索の方法の中に、HABTMの検索が書いてありますが、これはOR検索です。HABTMの検索というと、分かりやすいのはタグ検索だけど、タグ検索なら絞込み検索も欲しいという事で、やってみました。
コード
<?php public $filterArgs = array( array('name' => 'tag_id_and', 'type' => 'subquery', 'field' => 'Entry.id', 'method' => 'subqueryByTagsAnd'), ); public function subqueryByTagsAnd($data = array()){ $this->EntriesTag->Behaviors->attach('Containable', array('autoFields' => false)); $this->EntriesTag->Behaviors->attach('Search.Searchable'); $tag_id = explode('|', $data['tag_id_and']); $options = array( 'conditions' => array('tag_id' => $tag_id), 'fields' => array('entry_id'), 'contain' => $this->Tag->alias, ); if (( $c = count ( $tag_id )) !== 1 ) $options['group'] = 'EntriesTag.entry_id HAVING COUNT(EntriesTag.entry_id) = '.$c; $condition = implode(', ', $this->EntriesTag->find('list', $options)); if ( empty( $condition )) $condition = 'NULL'; return $condition; }
AND検索のところだけ抜粋。Search pluginの使い方はCakeDCのsearch pluginの記事が少ないので1個置いときますね。CakePHP Advent Calendar 2010 8日目 - kanonjiの日記を見てください。
サンプル
https://github.com/kanonji/CakePHP-Search-plugin-sample/commit/568a479c8b4eb297be5d962a01956c1afdf8eafa
サンプルの方も修正してあります。
元々OR検索だけどタグ検索あったのに、サンプルデータに2個以上のタグがついたデータが無かったのでそれも追加しました。こういうアソシエーションをカバーしつつ、程よい件数なデータを、さくっと生成する方法ってないのかなぁ?
解説
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]タグの絞込み機能 | HappyQuality
まずHABTMのAND検索は、このようにGROUP BY節とHAVING節を使うようです。以前にCakePHPじゃなくてオレオレフレームワークで作ってるときも、タグの絞り込み検索は作ったことがあったんですが、その頃はHABTMって考え方を知らなかったので、もっとトリッキーなSQLとテーブル構造でした。正直、あれはあまりいい方法とは言えなかった・・・
今回の方法は一応、http://k-brand.gr.jp/log/001734で同じ方法をとってるエントリーがもう1個あったので、多分いいんじゃないかなと、他に思いつかないし。
CakePHPでGROUP BY HAVINGを使う
'group' => 'Company.name HAVING count(Company.name)>= 2',cakephp: HAVING句を無理矢理使う 重複しているデータを取りたい | 俺なんて….com ― ORENANTE.com ― ... orz
ちょっとバッドノウハウっぽいんですが、こう書けるみたいです。
$this->Model->find('all', array( 'group' => 'Photo.id HAVING count(Photo.rating) > 5' ));http://www.tycamtech.com/php/cakephp/51-basics/125-mysql-and-the-qhavingq-expression-how-do-you-do-it-in-cakephp
英語のエントリーで同様の事を書いてるとこもありましたし、多分大丈夫だろうってことで。
一応、CakePHP 1.3.6のGROUP BY
のとこの処理はDboSource::group()です。
サブクエリーにGROUP BY HAVINGはめちゃくちゃ重たい
サンプルで作ったのは、タグ検索以外もタイトルのLIKE検索や数値の範囲などを組み合わせて、エントリーを取得する検索です。なのでタグ検索部分をWHERE節のIN演算子にサブクエリーとして使います。これはOR検索と同様なんですが、サブクエリー内にHAVING
を使ったとたん、ありえないくらい重たくなってしまいました。MySQLでサブクエリーにHAVING
使うと重たいってエントリーも見たけど、もしかしたら自分のデータベース設計のせいかもしれません。
<?php $query = $this->EntriesTag->getQuery('all', $options);
最初はOR検索と同様に、これでサブクエリーを作ってました。
SELECT `Entry`.`id`, `Entry`.`user_id`, `Entry`.`label`, `Entry`.`body`, `Entry`.`pageview`, `Entry`.`is_running`, `Entry`.`created`, `Entry`.`modified`, `User`.`id`, `User`.`username`, `User`.`email`, `User`.`label`, `User`.`password`, `User`.`created`, `User`.`modified` FROM `entries` AS `Entry` LEFT JOIN `users` AS `User` ON (`Entry`.`user_id` = `User`.`id`) WHERE `Entry`.`id` IN ( SELECT `EntriesTag`.`entry_id` FROM entries_tags AS EntriesTag LEFT JOIN tags AS Tag ON (`EntriesTag`.`tag_id` = `Tag`.`id`) WHERE tag_id IN (1,3) GROUP BY `EntriesTag`.`entry_id` HAVING COUNT(`EntriesTag`.`entry_id`) = 2 ) AND `Entry`.`is_running` = 1 LIMIT 20
そうすると全体としてはこんなクエリーになります。サンプルアプリだと、件数が少ないから重たいって感じはしないけど、3000件くらいのアプリでやったら、OR検索で100ms程度に対して30000ms程度と、使い物にならない感じに。
<?php $condition = implode(', ', $this->EntriesTag->find('list', $options));
仕方が無いので、サブクエリーの代わりとして先にクエリーを発行しました。
SELECT `EntriesTag`.`id`, `EntriesTag`.`entry_id` FROM `entries_tags` AS `EntriesTag` WHERE tag_id IN (1,3) GROUP BY `EntriesTag`.`entry_id` HAVING COUNT(`EntriesTag`.`entry_id`) = 2
SELECT `Entry`.`id`, `Entry`.`user_id`, `Entry`.`label`, `Entry`.`body`, `Entry`.`pageview`, `Entry`.`is_running`, `Entry`.`created`, `Entry`.`modified`, `User`.`id`, `User`.`username`, `User`.`email`, `User`.`label`, `User`.`password`, `User`.`created`, `User`.`modified` FROM `entries` AS `Entry` LEFT JOIN `users` AS `User` ON (`Entry`.`user_id` = `User`.`id`) WHERE `Entry`.`id` IN (8,17,26) AND `Entry`.`is_running` = 1 LIMIT 20
クエリーはこんな感じで、WHERE IN(Id列挙)になって、3000件くらいのだと100ms+100msくらいに落ち着きました。
$filterArgs
で'type' => 'subquery'
を使うと、Search plugin側で`Entry`.`id` IN ()
までは組み立ててしまいます。なのでsubqueryByTagsAnd()
の戻り値として、サブクエリーの代わりにIdを列挙した文字列でも通るって事でした。