2013年4月10日水曜日

CakePHP 2系で、独自カラムに対してPaginatorを使ってソートする方法

 
 このやり方が正しいかは解りませんが、
 そこそこ綺麗に解決したような気がするので、覚書として載せておきます。
 
 今回、割と特例的な対応だったので、全部MVCの中で完結させていますが、
 頻繁に使う用であれば、ComponentにするとかPaginator拡張するとかしても良いと思います。
 
 
 さて、まずは簡単な仕様の場合。
 
 CakePHPにはバーチャルフィールドという便利な仕組みがあります。
 「カラムaとカラムbをくっつけてカラムcにしたい!」なんて時にはこう書きます。
public $virtualFields = array(
    'column_c' => 'CONCAT(Hoge.column_a, " ", Hoge.column_b)'
);
参照:http://book.cakephp.org/2.0/en/models/virtual-fields.html
 
 見ての通り、SQL文をそのまま書く事になります。
 具体的には、SELECT句のフィールドに、そのまま
 SELECT CONCAT(Hoge.column_a, " ", Hoge.column_b) AS Hoge__column_c ...
 と記述されるようなイメージです。
 
 SQL文そのままなので、RDBMSが変わると動かなくなってしまうという欠点もあるのですが、
 バーチャルフィールドで定義したカラムは、通常のカラム同様に扱う事が出来るというメリットが有ります。
 なので、find時のorder句に、'column_c DESC'とか書いても、ちゃんとソートしてくれるのです。
 
 便利!
 
 
 さて、ちょっと複雑な仕様のカラムを追加する必要が出てきたとします。
 
 SQL文をそのまま書く、という事は、SQL文で解決できないような値はバーチャルフィールドで扱えません。
 例えば、MySQLの場合、順位を取得するクエリって簡単にはかけないんですよね。
 「複数のカラムが有り、それぞれのカラムの順位を合計した値を「ランク」として表示したい」
 なんて仕様は、さすがにバーチャルフィールドでは荷が重いわけです。
 
 そういう場合、どうするか?というと、
 Modelのコールバックメソッドの、afterFindの中で計算して設定します。
 
 諸々省略して雰囲気だけ伝えるコードを書くと
public function afterFind($results, $primary = false) {
 foreach($results as $key => $result) {
  // なんか計算したりして追加するカラムの値を作る
  $hoge = $aaa + $bbb;
  $results[$key][$this->alias]['hoge'] = $hoge;
 }
 return $results;
}
こんな感じで、hogeってカラムを増やします。
 参照:http://book.cakephp.org/2.0/en/models/callback-methods.html
 
 さて、ここで増やしたカラムに対して、Paginatorでソートしたい場合はどうすれば良いでしょうか?
 
 Helperはさすがにここまでやってくれないので、コントローラ辺りで自前でソートすることになります。
 幸い、CakePHPにはSet(2.2以降だとHashですね)という優秀なコアライブラリがあり、
 上記のafterFindが'Fuga'というモデル内の物だったとすると
$data = $this->Fuga->findAll(/* 省略 */);
$data = Set::sort($data, '/Fuga/hoge');
って記述で簡単にソートしてくれます。
 参照:http://book.cakephp.org/2.0/en/core-utility-libraries/set.html
 
 Paginatorでソートする場合、ソートのパラメータはデフォルトだとnamedに入るので、
 $this->request->named['sort']の値が特定の物だった場合に、
 Set::sortでソートしてやればよい事になります。
 
 先ほどから例に出している、Fugaモデルのhogeカラムで独自ソートしたい場合は、
$data = $this->paginate();
if (isset($this->request->named['sort']) && $this->request->named['sort'] == 'Fuga.hoge') {
 Set::sort($data, '/Fuga/hoge', $this->request->named['dir']);
}
$this->set('data', $data);

 とすればソートされて表示されます。
 
 
 ヤッター!これで独自カラムでソートができたぞー!
 
 と喜び勇んでいると、「デフォルト時にhogeでソートしておいてほしい」
 なんて要求が来たりするわけです。
 
 Paginatorでソートする場合の、クエリの条件は、Controllerの$paginateプロパティに記述します。
 
 これが、もし、fugasテーブル上のpiyoという、存在するカラムに対するソートであれば、
$paginate = array(
 'order' => array('piyo' => ASC)
);
と記述してしまえば良いのですが、こちらのパラメータ、そのまま、SQLのORDER BYに入るので、
 hogeカラムのように、テーブル上にもバーチャルフィールド上にも存在しないカラム名を指定すると
 「そんなカラムねーよばーか」ってMySQLに怒られてしまいます。
 
 仕方がないので、独自ソートするときのif文条件を変更して、
 sortパラメータが無い場合も、hogeでソートする
 というように変更するのが、手っ取り早い対応になるのですが、
 これを行うと、HTML上のasc/descの表示が狂ったり、クリック時の挙動がおかしくなります。
 
 何故か、というと、CakeRequestのparamsに格納された、pagingという値(連想配列です)を元に、
 「今ソートされているキーはどれか?」を判定して、表示上に取り入れているからなんですね。
 具体的には、PaginatorHelperのsortKeyというメソッドで判定しているので、その辺を読んでみてください。
 参照:http://book.cakephp.org/2.0/en/core-libraries/helpers/paginator.html#PaginatorHelper::sortKey
 
 (書いていて気づいたのですが、デフォルト時のソート云々抜きにして、
 Set::sortでデータだけソートしている状態だと、挙動がおかしくなっていた可能性は高いですねー。
 検証してないので、一見うまく動いてくれてる可能性もありますけども。)
 
 ここを解決するためには、Set::sortで独自ソートした後で、$this->request->params['paging']['order']の値を書き換えます。
 paramsの値は直接アクセスして書き換えることが出来ないので、CakeRequestのoffsetSetを使います。
 纏めると、先ほどのif文の中身は、こうなります。
if(/* 省略 */) {
 Set::sort($data, '/Fuga/hoge', $this->request->named['dir']);
 // 現在セットされているpagingパラメータを取得、値を上書きしてから再セット
 $pagingParams = $this->request->offsetGet('paging');
 $pagingParams['Fuga']['order'] = array('Fuga.hoge' => $dir);
 $this->request->offsetSet('pagin', $pagingParams);
}
これにて、無事、独自追加したフィールドに対するページングがうまく働くようになりました。
 
おわり!

0 件のコメント:

コメントを投稿