CakeDCのsearch pluginの記事が少ないので1個置いときますね。CakePHP Advent Calendar 2010 8日目
CakePHP Advent Calendar 2010 8日目
http://cakephp.jp/modules/newbb/viewtopic.php?viewmode=flat&topic_id=2510&forum=16
CakePHP Advent Calendar 2010の順番が回ってきました。8日目のkanonjiです。
最近立て込んでいたので、実はみんなの記事を今日読みました。どれも参考になる記事で、結構CakePHPを分かってきたかなと思っていたんですが、まだまだだって事が分かりました。
前日のMASA-Pさんの記事「【CakePHP】CakePHP Advent Calendar2010(day 7) : Tips for Routes | ECWorks Blog」では、ちょうど最近はまっていたRoutesのtipsを教えてもらったので、早速後でroutes.phpを書き直そうと思います。
とあるページの特殊な設定をしたら、別のページが影響を受けたりと、まるで黒魔術*1の様だと頭を抱えてましたが、どうやら使い方を分かってなかったようですね。
CakeDC search plugin
本題ですが、CakePHPで複雑な検索機能を実現する GitHub - CakeDC/search: Search Plugin for CakePHP を使ったので、その使い方を紹介します。
使い始めた時は情報が少なくて、Readme.md を頼りにやってみたんですが、実は正しく使えてるかちょっと自信がありません。試行錯誤してたら動いたっぽい、そんな中途半端な情報ですが、発表しておけば次に繋がるかもという事で。
サンプルとして、やっつけですがこんな構成でデータベースを作りました。
User hasOne Profile User hasMany Entries User HABTM Groupp Entry HABTM TAG
図に描けばよかったんですがアソシエーションはこんな感じです。ユーザーはグループに所属していて、プロフィールを別に持っています。ユーザーが記事を投稿して、その記事にはタグが付けられている。そんな簡易ブログの様な構成です。
なお、Profileを分けておきながら、上手く検索対象に出来なかったので、今回は使いません。
groupsというテーブルだと、SQL Syntax errorが出てしまったので、grouppsなんて変なテーブル名*2になってます。
これを元に、タグの有無や投稿ユーザーやグループなどを条件として、記事を検索する機能を作ります。CakePHPのcssそのままなので、ちょっと画像がでかいですが、こんな画面です。
準備
$ pwd /path/to/app/plugins $ git clone https://github.com/CakeDC/search.git
Search pluginはコントローラーとモデルに、それぞれ専用のプロパティを書くことで、検索の動きを決めていけます。記事を検索するのでEntryモデルと、SearchsControllerを使いました*3。
<?php echo $this->Form->create();?> <fieldset> <legend><?php __('Seach Entry'); ?></legend> <?php echo $form->input('label',array('label'=>'Entry.label')); echo $form->input('email',array('label'=>'User.email')); echo $form->input('pageview',array('multiple'=>'checkbox','options'=>$pageviews,'label'=>'Entry.pageview')); echo $form->input('groupp_id',array('multiple'=>'checkbox','options'=>$groups,'label'=>'Groupp HABTM User')); echo $form->input('tag_id',array('multiple'=>'checkbox','options'=>$tags,'label'=>'Tag HABTM Entry')); echo $form->input('word'); ?> </fieldset> <?php echo $this->Form->end(__('Search', true));?>
APP/views/search/index.ctp
まずViewに検索フォームを作ります。通常ならモデル名やフィールド名をそのまま書きますが、Seach pluginが間に入るので、モデル名は要りません。ちなみにmethodはPOSTです。
<?php class SearchsController extends AppController { public $name = 'Searchs'; public $uses = 'Entry'; public $components = array('Search.Prg'); public $presetVars = array( array('field' => 'label', 'type' => 'value'), array('field' => 'email', 'type' => 'value'), array('field' => 'pageview', 'type' => 'checkbox'), array('field' => 'groupp_id', 'type' => 'checkbox'), array('field' => 'tag_id', 'type' => 'checkbox'), array('field' => 'word', 'type' => 'value'), );
APP/controllers/searchs_controller.php
コントローラーの$presetVarsプロパティに、上記の様にViewに書いたフォームのname属性に対応する形で書いていきます。チェックボックスの様に、複数選択が出来る項目は 'type' => 'checkbox'。テキストボックスやラジオボタンの様に、単一データの場合は 'type' => 'value' にします。ほかに 'type' => 'lookup' というのや 'type' のほかに'model'や'formField'や'modelField'がありますが、使い方を知らないので触れません。
<?php class Entry extends AppModel { var $name = 'Entry'; public $actsAs = array('Search.Searchable'); public $filterArgs = array( array('name' => 'label', 'type' => 'value'), array('name' => 'email', 'type' => 'value', 'field' => 'User.email'), array('name' => 'pageview', 'type' => 'query', 'method' => 'pageviewBetween'), array('name' => 'groupp_id', 'type' => 'subquery', 'field' => 'User.id', 'method' => 'searchByGroupps'), array('name' => 'tag_id', 'type' => 'subquery', 'field' => 'Entry.id', 'method' => 'searchByTags'), array('name' => 'word', 'type' => 'query', 'method' => 'findWithLike'), );
APP/models/entries.php
次はモデルに$filterArgsプロパティを書いていきます。$presetVarsの'field'に対応する形で、どう検索するかを設定します。この設定が頑張りどころなので、以下に検索方法ごとに分けて紹介します。
色々な検索の方法
自分が作った事がある方法だけですが、上記の設定に対応した形で紹介していきます。最初にも書きましたが、Search pluginの作者の意図した使い方かどうかわからないのと、もっと他の形の検索も実装出来るという事にご注意ください。
単純な完全一致
入力した値が、指定のフィールドにあるかどうかを調べるのは簡単です。
<?php array('name' => 'label', 'type' => 'value'),
今回はEntryモデルを使っているので、entriesテーブルのlabelフィールドに値が完全一致であるかどうかという設定です。
SQL的には WHERE `Entry`.`label` = ? こんな感じです。
<?php array('name' => 'email', 'type' => 'value', 'field' => 'User.email'),
JOINされているアソシエーションなテーブルに対しては、'field'にモデル名付きで設定します。
BETWEEN文的な検索
<?php array('name' => 'pageview', 'type' => 'query', 'method' => 'pageviewBetween'),
エントリーにページビューが記録されているという形で、BETWEEN文で大体の幅に絞って検索します。本来は'type' => 'expression'というのがBETWEEN用にあるんですが、'expression'ではチェックボックスを選んで幅を決められなかったので、'type' => 'query'を使いました。'query'は指定できるtypeの中で一番自由度が高いものの様です。
<?php public function pageviewBetween($data){ $input = $data['pageview']; $inputs = explode('|', $input); foreach($inputs as $input){ switch ($input) { case '1': $input = array(0, 9); break; case '2': $input = array(10, 49); break; case '3': $input = array(50, 99); break; case '4': $input = array(100, PHP_INT_MAX); break; default: $input = null; } if($input) $coditions['or'][] = array("{$this->alias}.pageview BETWEEN ? AND ?" => $input); } return $coditions; }
APP/models/entries.php
'type' => 'query' を使う場合は'method' => 'pageviewBetween'の様に処理をするメソッドを設定します。こんなpageviewBetween()メソッドをEntryモデルに作りました。'type' => 'query'では、最終的にModel->find()などで使う$options['conditions']をreturnする作りにします。チェックボックスからの値によって、BETWEENなconditionsを組み替えてreturnすれば出来上がりです。
HABTMを検索(OR検索)
<?php array('name' => 'groupp_id', 'type' => 'subquery', 'field' => 'User.id', 'method' => 'searchByGroupps'), array('name' => 'tag_id', 'type' => 'subquery', 'field' => 'Entry.id', 'method' => 'searchByTags'),
タグやグループの様なHABTAMな関係を検索します。今回はグループとタグの2つがあります。それぞれ、指定したグループに所属するユーザーが投稿したエントリーと、指定したタグが付いてるエントリーを検索します。
HABTAMの検索には'type' => 'subquery'を使います。これはField IN(subquery)の様なIN演算子を使うSQLを生成できます。サブクエリー自体は、'method'で指定したメソッドで自分で組み立てる事になります。
HABTMの中間テーブルがAppModelオブジェクトになってしまう問題の対応 - cakephperの日記(CakePHP, Laravel, PHP)
あと、HABTMの中間モデルを使うため、各モデルの$hasAndBelongsToManyのとこに、'with'=>'モデル名'*4を付ける必要があります。加えて、付けるからには中間モデルもBakeで作るとかしておく必要があります。これについては上記が詳しいですが、自分の環境では'with'=>''だと動作しませんでした。
<?php public function searchByGroupps($data = array()){ $this->User->GrouppsUser->Behaviors->attach('Containable', array('autoFields' => false)); $this->User->GrouppsUser->Behaviors->attach('Search.Searchable'); $query = $this->User->GrouppsUser->getQuery('all', array( 'conditions' => array('groupp_id' => explode('|', $data['groupp_id'])), 'fields' => array('user_id'), 'contain' => $this->User->Groupp->alias, )); return $query; }
こんな感じでサブクエリーを作ります。タグのほうもモデル名とキー名以外は同じなので省略です。
実はこれはReadmeにあったメソッドのほぼコピペです。
public function findByTags($data = array()) {
https://github.com/CakeDC/search/blob/master/readme.md
$this->Tagged->Behaviors->attach('Containable', array('autoFields' => false));
$this->Tagged->Behaviors->attach('Search.Searchable');
$query = $this->Tagged->getQuery('all', array(
'conditions' => array('Tag.name' => $data['tags']),
'fields' => array('foreign_key'),
'contain' => array('Tag')
));
return $query;
}
ReadmeではContainableをアタッチしてましたが、後述するコントローラーのアクション内でContainableを使ったからか、なくても動いたので削っています。
CakePHPのsearch pluginのサンプルコードと記事をちょっと修正。Containableは必要でした - kanonjiの日記のように修正しました。上記のコードも修正しています。
また、'conditions' => array('Tag.name' => $data['tags']), となってますが、これはチェックボックスではなく、タグを入力しての完全一致になってます。チェックボックスでのOR検索にしたかったので、'conditions' => array('groupp_id' => explode('|', $data['groupp_id'])), の様にしました。
HABTMを検索(AND検索)
CakePHPのSearch pluginでタグの絞込み検索を作る(AND検索) - kanonjiの日記で追加しました。
LIKE演算子をふんだんに使ったフリーワード検索
<?php array('name' => 'word', 'type' => 'query', 'method' => 'findWithLike'),
完全一致は簡単ですが、部分一致で検索するにはやっぱり'type' => 'query'を使います。LIKE検索なのであまり多用・過信できませんが、やっぱり部分一致はしたいです。
<?php public function findWithLike($data = array()){ $conditions['or'][] = array("{$this->alias}.label LIKE" => "%{$data['word']}%"); $conditions['or'][] = array("{$this->alias}.body LIKE" => "%{$data['word']}%"); $conditions['or'][] = array("{$this->User->alias}.username LIKE" => "%{$data['word']}%"); $conditions['or'][] = array("{$this->User->alias}.email LIKE" => "%{$data['word']}%"); $conditions['or'][] = array("{$this->User->alias}.label LIKE" => "%{$data['word']}%"); return $conditions; }
'type' => 'query'はconditionsを組み立ててreturnすれば良いので、見ての通りです。
コントローラーのアクションを作る
ここまでやったら、あとはコントローラーにアクションを作るだけです。
<?php public function index(){ if($this->data || $this->passedArgs){ $this->Entry->Behaviors->attach('Containable'); $this->paginate['contain'] = array( 'User' => array( 'Groupp', 'Profile', ), 'Tag', ); $this->Prg->commonProcess(); $this->paginate['conditions'] = $this->Entry->parseCriteria($this->passedArgs); $this->paginate['conditions']['Entry.is_running'] = 'true'; $this->set('entries', $d = $this->paginate());//debug($d); } $tags = $this->Entry->Tag->find('list',array('fields'=>'label')); $groups = $this->Entry->User->Groupp->find('list',array('fields'=>'label')); Configure::load('selection'); $locations = Configure::read('selection.location'); $locations = array_combine($locations, $locations); $pageviews = Configure::read('selection.pageview'); $this->set(compact('tags','groups','locations','pageviews')); }
<?php if($this->data || $this->passedArgs){
Search pluginはフォームからPOSTでデータを受け取り、Named parameters*5に詰めなおしてから、1度リダイレクトします。なので、フォームから入力があるかどうかは$this->passedArgsも見る必要があります。
ちなみに、Named parametersへの詰めなおしとリダイレクトは $this->Prg->commonProcess(); がやってます。
<?php $this->paginate['conditions'] = $this->Entry->parseCriteria($this->passedArgs);
Named parametersが着たらparseCriteria()に渡すと、これまでにEntryモデルに書いた設定によってconditionsが生成されます。これを$this->paginate()にでもModel->find()にでも、渡してあげれば検索が出来ます。あとは、検索結果画面のViewを作れば完成です。
http://d.hatena.ne.jp/aroundthedistance/20090728/1248784179
ちょっと確認はしてませんがpaginateCoung()をオーラーライドする必要があるかもしれません。自分がSearch pluginを使ったアプリでは、上記で解説されてる方法でオーバーライド済みでした。
<?php $this->paginate['conditions'] = $this->Entry->parseCriteria($this->passedArgs); $this->paginate['conditions']['Entry.is_running'] = 'true';
parseCriteria()の後は普通の conditions なので、このように別の条件を追加したり出来ます。これは公開/非公開フラグがtrueである条件を追加しました。
一応サンプルを置いておきます。
環境
Mac | Mac OS X 10.5.8(Leopard) |
MAMP | 1.7.2 |
CakePHP | 1.3.6 |
php | 5.2.6 |
CakeDC Search plugin | updating readme · CakeDC/search@668eb68 · GitHub |
この記事と、上記サンプルはこんな環境で確認してます。
次のCakePHP Advent Calendar 2010
次の担当はhttp://cake.eizoku.com/advent/によると id:ixcy さんです。が、リンク先を知らないので、分かり次第リンクします><
どんなCakePHPの記事が出てくるか、CakePHP Advent Calendar 2010では色んな発見があるので、楽しみです。
リンク先が分かったので貼りました。ついでに見直してタイポや変な言い回しを少し直しました。