141105-CakePHP

 CakePHPのページネーション(Pagination)でストアードルーチンを使った並べ替えができない問題が発生しました。しかし、何とかその解決方法がわかったのでメモがてら下記に明記しておきます。

開発環境

 今回の主要な環境は以下の通りです。

OS
CentOS Ver. 6.5
CakePHP
Ver. 2.5.5
DB
MySQL Ver.5.5.40

前提条件とプログラム(ソース)

 今回、問題が発生したのはコントローラーでページネーションを利用し、並べ替えとして「order」にフィールドを設定したところ、正常に並べ替えが行われませんでした。そのソースは以下の通りです。

<?php
class CarsController extends AppController {
    public $uses = array('Car');
    public $paginate = array(
        'fields' => array(
            'Car.id',
            'Car.name',
            'latest_use_date(Car.id) AS latest_date',
        ),
        'order' => array(
            'latest_use_date(Car.id)' => 'asc',
            'Car.id' => 'asc',
        ),
        'limit' => 10,
    );

    public function index() {
        $cars = $this->paginate('Car');
        $this->set(compact('cars'));
    }
}

 CakePHPを使ったことがある方なら通常のページネーションの書き方かと思うのですが、唯一「ん?」と思われるのが「fields」と「order」にある「latest_use_date(Car.id)」では無いでしょうか。

 この「latest_use_date(Car.id)」は何なのかというと、MySQLのストアードルーチン「ストアドファンクション」で車両ID(Car.id)を入れると、使用履歴から最新の日付を返すというMySQL内で使用するオリジナルの関数です。「fiedls」ではその値を取得して「latest_date」という名前で列に表示します。そして、「order」でこの「latest_date」で昇順に並べ替えを行います。

問題発生

 しかし、この結果を出力すると内容は正常に表示されるのですが、並べ替えが正常に行われません。SQLを参照してい見ると下記のように出力されています。

SELECT
    Car.id,
    Car.name,
    latest_use_date(Car.id) AS latest_date,
FROM
    Car
ORDER BY
    Car.id asc
LIMIT
    10;

 SQLにの「ORDER BY」にストアードファンクションの「latest_use_date(Car.id)」が無くなっています。ちなみにページネーションではなくモデル等で「order」にストアードファンクションを記入しても、正常に並べ替えが行われます。

hasField()関数とは

 この問題を解決するために原因の追及を行いました。コアファイル内の「/lib/Cake/Controller/Component/PaginatorComponent.php」がコントロール内のページネーションプログラムとなり、中をいろいろと調べた結果「validateSort」関数の下記のプログラムが並べ替えを行う部分になります。

public function validateSort(Model $object, array $options, array $whitelist = array()) {
    :(省略)
    $order = array();
    foreach ($options['order'] as $key => $value) {
        $field = $key;
        $alias = $object->alias;
        if (strpos($key, '.') !== false) {
            list($alias, $field) = explode('.', $key);
        }
        $correctAlias = ($object->alias === $alias);

        if ($correctAlias && $object->hasField($field)) {
            $order[$object->alias . '.' . $field] = $value;
        } elseif ($correctAlias && $object->hasField($key, true)) {
            $order[$field] = $value;
        } elseif (isset($object->{$alias}) && $object->{$alias}->hasField($field, true)) {
            $order[$alias . '.' . $field] = $value;
        }
    }
    $options['order'] = $order;
    :(省略)
}

 「order」内にセットされた配列は上記プログラムの「foreach」でキー(フィールド名)と値(ascの昇順・descの降順)に分離されてループします。分離された並べ替えのフィールドは「if ($correctAlias && $object->hasField($field))」でフィールド名が存在するかチェックされます。今回のストアードルーチンの「latest_use_date(Car.id)」はDBのフィールドには存在しないのでFalseが返されスルーされます。

 次に「elseif ($correctAlias && $object->hasField($key, true))」の部分ですが、上記のIF文内容とよく似ています。しかし、よく見るとhasField()関数の第2引数に「True」がセットされています。何が違うのでしょう?

 そこでこの「hasField()」関数が何なのかを調べてみました。するとCakePHPのドキュメントに明記されていました。そこには

Model::hasField() は、モデルが実際に持っているフィールドを一番目の引数で渡すと true を返します。hasField() の二番目の引数を true にすることによって、バーチャルフィールドもチェックされるようになります。上記の例を用いれば、

$this->User->hasField('name');
// 「name」というフィールドが実在しないため false を返します。

$this->User->hasField('name', true);
// 「name」というバーチャルフィールドがあるため true を返します。

 ここで出てくる「バーチャルフィールド」というのがキーワードです。正直私は知りませんでした。

バーチャルフィールドとは

 このバーチャルフィールドについては先ほどhasField()関数について書かれていたCakePHPのドキュメントのページがバーチャルフィールドに関するドキュメントでした。そこの冒頭には下記のように書かれています。

バーチャルフィールドは任意のSQL表現を作り、それをモデルのフィールドとして割り当てることを可能にします。これらのフィールドは保存することはできませんが、読み込み操作時にモデルの他のフィールドと同じように扱われることになります。また、モデルの他のフィールドと同じように、モデルのキーを元に配置されます。

 つまり、今回のように架空のフィールドであるストアードファンクションなどをフィールド名としてセットすることができる機能で、下記のようにモデルに明記すればいいそうです。

public $virtualFields = array(
    'name' => 'CONCAT(User.first_name, " ", User.last_name)'
);

 こうすることで架空のフィールド名が追加されます。

 そして、先ほどのhasField()関数は第2引数にTrueをセットすることでバーチャルフィールドが存在するかどうかを確認することができるとのことです。

解決

 上記の内容からモデルに下記ソースを追加。

public $virtualFields = array(
    'latest_date' => 'latest_use_date(Car.id)'
);

 次にコントローラー内のページネーションの設定を下記のように変更しました。

<?php
class CarsController extends AppController {
    :(省略)
    public $paginate = array(
        'fields' => array(
            'Car.id',
            'Car.name',
            'latest_date',
        ),
        'order' => array(
            'latest_date' => 'asc',
            'Car.id' => 'asc',
        ),
        'limit' => 10,
    );
    :(省略)
}

 こうすることによってコアプログラムの「$object->hasField($key, true)」がTrueとなって並べ替えが行われるようになりました。

最後に

 なかなか解決策を見つけることができなかったのですが、今回はドキュメントに書いてある内容を知っていればすぐに解決することができたかもしれません。まさに「灯台下暗し」です。まだまだ勉強が足りませんでした(^^ゞ

【参考サイト】
バーチャルフィールド — CakePHP Cookbook 2.x ドキュメント
CakePHP2 Paginatorコンポーネント用いてvirtualFieldで定義したjoin先テーブルのカラムでソートをする方法。 – Qiita

はじめてのCakePHP―日本でも人気!無料で使えるPHP用フレームワーク (I・O BOOKS)

著者/訳者:樺嶋 芳充

出版社:工学社( 2014-08 )

定価:¥ 2,484

Amazon価格:¥ 2,484

単行本 ( 223 ページ )

ISBN-10 : 4777518477

ISBN-13 : 9784777518470