$controller->paginate()で複数の列をソートの条件にする

ということを調べてみた。
CakePHPのバージョンは1.2.5。
使ってるDBMSMySQL

複数の列でソートしたい

CakePHPのpaginatorは便利。例えば、Controller内で

$this->set('data', $this->paginate('modelname', $options));

を実行して、View側で

<th>
  <?= $paginator->sort('hoge', 'columnname1'); ?>
</th>
<th>
  <?= $paginator->sort('hoge', 'columnname2'); ?>
</th>
<th>
  <?= $paginator->sort('hoge', 'columnname3'); ?>
</th>

みたいに記述するだけで、各カラムヘッダにリンクが貼られ、
それぞれをクリックすると、各columnnameでのソートが可能になる。


けど、複数のカラムを組み合わせた条件でソートしたいという瞬間も
(たとえば「入社年月日の降順、氏名の昇順」とか)
この世の中には結構あるわけで、そういうときどうするかを調べてみた。


$model->beforeFind()を使って処理

http://aoyama.accata.com/archives/1813
こちらの方が書いているように、$controller->paginateメソッドは
毎回$model->beforeFindメソッドを実行してくれるので、
任意のmodel内でfunction beforeFind($queryData){} を
オーバーライドすることで、ソート条件(=生成されるSQLのORDER BY句)を
編集することができる。

<?php
class MyModel extends AppModel {

  var $name = 'MyModel';

  function beforeFind($queryData) {
    $queryData['order'] = array(
       array('入社年月日' => 'desc'),
       array('氏名' => 'asc'),
    );
    return $queryData;
  }
}

こんな具合。これで、生成されるORDER BY句は

ORDER BY 入社年月日 desc, 氏名 asc

になる。確かになるんだけど。

ソート条件は毎回動的に生成したいお

今回作りたいのは、毎回同じ条件でソートされるものじゃなくて、
ユーザがその都度気分に応じてソート条件を変えられるものに
したかった。上記のようにmodel内のbeforeFindメソッド内で
静的に$queryData['order']を編集していては、paginateどころか
普通に$model->find()を使ってデータを取得したいときも、
毎回同じ条件でソートされることになってしまう。それはいやだ。
ではどうしましょう。


複数のソート条件設定用メソッドを作っとく

MyModelクラス内を以下のようにしておく。

<?php
class MyModel extends AppModel {

  var $name = 'MyModel';

  // デフォルトのソート条件は何もなし
  var $order = array();

  function setOrder($order) {

    // controllerから渡された$orderを変数に持っておく
    $this->order = $order;

  }

  function beforeFind($queryData) {

    // $queryData['order'] の最初に、そのとき
    // $this->orderに代入されている検索条件を追加する
    array_unshift($queryData['order'], $this->order);

    // 終わったら$this->orderは空にしておく
    // (じゃないと次回以降の検索時に条件が適用されてしまうので)
    $this->order = array();

    return $queryData;

  }

}
?>

で、controller側では、$this->paginate()を実行する前に、
必要であれば$this->Model->setOrder($order)を実行しておく。

<?php
class MyController extends AppController {

  var $name = 'MyController';
  var $uses = array('MyModel');
  var $helpers = array('Html', 'Form', 'Paginator');

  function hoge() {

    // paginate前に(必要に応じてソート条件をセット)
    $order = array(
        '入社年月日' => 'desc',
        '氏名' => 'asc'
    );
    $this->MyModel->setOrder($order);

   // paginate実行
    $this->set('data', $this->paginate('MyModel'));

  }

}
?>

setOrder()で渡す配列の中身を、都度好きなように変えれば、
毎回必要な分だけ好きな順序でソートした結果をもって
ページネートできる。


と思ったんだけど。


paginate結果にソートが反映されていない


$this->MyModel->setOrder($order) でセットしたはずの
入社年月日 desc, 氏名 asc のソート条件が結果に反映されない。
なんで?なんぞ?


と思ってcake/cake/libs/controller/controller.php内にある
function paginate を見てみると、controller内で一回$this->paginateを
実行すると、二回Model->find()が呼び出されることを知った。

<?php
class Controller extends Object {
(中略)
        if (method_exists($object, 'paginateCount')) {
            $count = $object->paginateCount($conditions, $recursive, $extra);
        } else {
            $parameters = compact('conditions');
            if ($recursive != $object->recursive) {
                $parameters['recursive'] = $recursive;
            }

            // ここと
            $count = $object->find('count', array_merge($parameters, $extra));

        }
        $pageCount = intval(ceil($count / $limit));

        if ($page === 'last' || $page >= $pageCount) {
            $options['page'] = $page = $pageCount;
        } elseif (intval($page) < 1) {
            $options['page'] = $page = 1;
        }
        $page = $options['page'] = (integer)$page;

        if (method_exists($object, 'paginate')) {
            $results = $object->paginate($conditions, $fields, $order, $limit, $page, $recursive, $extra);
        } else {
            $parameters = compact('conditions', 'fields', 'order', 'limit', 'page');
            if ($recursive != $object->recursive) {
                $parameters['recursive'] = $recursive;
            }

            // ここね。
            $results = $object->find($type, array_merge($parameters, $extra));

        }
(中略)


この二回のfindのうち、一回目はページネートのページ数を求めるためだけに
使われる、検索結果の件数を得る目的のものだ。
したがって、一回目のfindはソート条件とかまったく関係ない話なのであるが、
そのときにもMyModelでオーバーライドしたbeforeFindが実行されて、

<?php
class MyModel extends AppModel {

  (中略)

  function beforeFind($queryData) {

    // $queryData['order'] の最初に、そのとき
    // $this->orderに代入されている検索条件を追加する
    array_unshift($queryData['order'], $this->order);

    // 終わったら$this->orderは空にしておく
    // (じゃないと次回以降の検索時に条件が適用されてしまうので)
    $this->order = array();

    return $queryData;

  }


しっかりと$this->orderを空にしていたのだった。
つまり、いざ二回目のfind、いわば本当にpaginateの結果を得るための
findを実行するときには、$orderには何の情報も入っていなかったために、
setOrder($order)でセットしたソート条件が表示結果に反映されていなかったのだった。


しかし、対象データの件数を得るためにSELECT文を発行するってのは
どうなんだろう?DBへのアクセスは一回に(最初からちゃんと使う目的でfind)
しておいて、その結果をphpのcount()とかで数えたほうがいいんではなかろうか???


とかいってるけど、その辺、特にベンチとか取ってるわけではないので
この辺にしておく。


うーんじゃぁcountのときはbeforeFindスルーで

今回の場合、setOrderで検索条件をセットしたあとに実行した
一回のpaginate内で実は二回のfindが実行され、その一回目のfindに
呼び出されたbeforeFindによって$orderが初期化されていたのが、
paginateで取得した結果がソートされていない原因だったわけだ。


えっとじゃぁどうしよう。っていうか今回のケースに限らず、
そもそもfind('count')のときって件数さえ取れればいいんだから
ソートは関係ないよな。とか思ったり。。


とりあえず今回は
「MyModelのbeforeFindが実行されたとき、それがfind('count')だったら
 $orderには触れない、初期化もしない」
ということにしよう。

<?php
class MyModel extends AppModel {
(中略)
  function beforeFind($queryData) {

    // 実行中のfindの種別が'count'以外だった場合のみ、
    // ソート条件を追加し、$orderを初期化する
    if ($this->findQueryType != 'count' )
        array_unshift($queryData['order'], $this->order);
        $this->order = array();
    )
    return $queryData;

  }


もうちっとなんかスマートなやり方があるんではないかとか
思いつつ、とりあえず、これで望む動作をしてくれるようになった。
ので、今回はこれでよしとした。しました。