2007年11月14日

CodeIgniter(に限らず):検索結果とページングの嫌な関係

前回、ページングが実装できた!とおもいきや実は重大な落とし穴がある、と書きましたが、今回はその辺の話を書きたいとおもいます。今回はコードは一切でてきません(愚痴ですから…)。

まずその落とし穴ですが、前回までのスクリプトでなにがしかの検索をしてみます、まあページングされないと困るので、わざと比較的多くのデータが引っかかるような語句、例えば「市」などで検索してみます。

いちいち画像は貼りませんが、一見大丈夫そうに見えます。てゆーかこの時点(1ページ目表示)ではなんの問題もありません。きちんと「市」でフィルタリングされた結果が返っています。

総件数も、ページングリンクのLASTの部分にポインタを合わせてみるとわかりますが、フィルタリングなしの時は590だったオフセット値が430に減っているので、きちんと数えられているとおもいます。

そこで次へのリンクをクリックします。表示が2ページ目に移動します。一見大丈夫そうです。が、よく見ると「日高町」とか「枝幸町」「安平町」など「市」でフィルタリングした場合は引っかからないはずの新市町村の名前が見えます。

あれれ?とおもってデバッグ表示を見てみると、POSTデータはありませんなどと書いてあるし、SQLもWHERE nc like '%%'になっています。なんでだ?とおもってもう一度リロードしたり何度もページングリンクをクリックしたり…。

でも結局検索フォームをサブミットした直後のページはよいのですが、ページングリンクを一度でもクリックしてしまうとダメです。要するに検索語句がリセットされちゃっているんですね。

まあここまで読んで「そんなの当たり前だろ!」と言える人はすでに相当プログラミングに慣れている方か、プロの方でしょう。私のように趣味でやってるような素人プログラマはこれって結構ハマルポイントなんじゃないでしょうか?(オレダケ?)

というのも…(ここから先は純粋に愚痴です)、そもそもページングする時ってどういう時なんでしょうか?静的なHTMLであれば長くなりそうだったら当然そこでファイルを分けるなりなんなりするでしょう。つまりページングが必要な場合というのはなんらかの動的なデータ、しかも条件によって長さ(というか数)が変化するようなデータを表示する時なのではないでしょうか?

そしてそうした状況は、もうほとんど検索結果表示画面なんじゃないでしょうか?それ以外だと例えば情報系?(ITMediaとかAllAboutとかCodeZineとか…)のサイトによくある一つの記事を数ページにわけて表示したりとか、Blogなどでも長い記事を数ページに分けて表示したりしているところもあるようですが、結局これらもデータベースには元々の長い記事があって、それを表示時にページングしているという点では、ある種の検索結果だとおもうわけです。

んでそういう検索結果をページングする際に、まあいろいろな方法があるとおもいますが、共通していることは検索に用いた語句なりIDなり、いわゆる検索条件といったものを保持していなければならないということです。

例えばこのBlogでエサにしている自作の汚い手続き型スクリプトでは、ページング部分で生成しているURLリンクにgetクエリ文字列として渡しているわけです。
http://localhost/gappei.php?nc=市&page=3
のような感じです。(※もちろん日本語はURLエンコードしたりしてますが…)

フレームワークと言ってよいのかどうか知りませんがPHPにはPEARというライブラリ?があります。その中のPagerなどは上記のようにformデータをgetで受け取ってる場合は特に何もしなくても上のようにクエリ文字列を付加したリンクを生成してくれます。

もちろんPEARのPagerでもformデータがpostになっている場合は、上の状況と同じようになります(--;こうした場合(formがpost)通常のHTMLだけではAリンクからformをsubmitすることができないので、<a href="javascript:document.form1.submit();">のようにjavascriptを使ったりします。

いずれにしろ検索条件を次に移動するページに伝えてあげないとならないわけですが、ここからがハマリ所でして…。

CodeIgniterで上記の方法を再現しようとすると、まずgetでformデータを送ることはできません。これはユーザガイドのセキュリティに明記されています。もちろんここにあるようにクエリ文字列オプションを有効にすれば使えますが、オフにした場合その他の影響が大きすぎて普通はしないでしょう。

そうなると次はjavascriptとなるわけですが、paginationのconfig配列のbase_urlのところにjavascript:document.form1.submit();などと書いても無意味ですし、そもそも仮に↑のように書けたとしても今度はページングに使用するページ数なりオフセット値なりを渡すことができません。

通常そうした場合はhiddenフィールドなどを使うとおもうのですが、ページネーションの生成するリンクはどう見てもpostデータをサブミットするような構造にはなっていない、というかmod_rewriteを通すことを前提としたようなURI(/class/method/parameterのような)構造を生成してしまうわけです。

ではいったい検索条件を次ページに伝えるにはどうすればよいのでしょうか?一般的にページを跨いで何かを保持するにはセッションを使います。ショッピングカートの作成例なんかでよく説明されています。

ですがここからが今回の本題なんですが「検索条件ごときにいちいちセッションなんか使う」ものなんでしょうか?というのもセッションなどという大袈裟なモノを持ち出さなくても、上記のようにgetでクエリ文字列のリンクを生成したり、javascriptでsubmitしたり…というようにやり方はいろいろ考えられるわけです。

しかもCodeIgniterはデフォルト状態ではgetが使えないわけです。んでページネーションクラスを使用するとpostを自動的にサブミットしてくれるわけでもなく、単にoffsetパラメータ付きのURIを生成するだけです。ページネーションを必要とする場面がほとんどなんらかの検索結果の表示にも関わらずです。

結論から言うとCodeIgniterで検索条件を維持しつつページングする場合は、セッション(またはクッキー)を使うしかありません(※たぶん、ってか他の方法があったら是非教えて欲しいです)。これは別にCodeIgniterに限った話じゃないかもしれません。例えばCakePHPでもデフォルトのページャーは同じようなものですし、Pearも上述のとおりです。

ですがCakeの場合はURIが/class/method/parameterのような形になっていても?hoge=hage&foo=barみたいなクエリ文字列を付け足せますし、Pearの場合はgetの時は自動的にクエリ文字列付きのURIを生成してくれます。

でもCodeIgniterのページネーションは何もやってくれません。そしてクエリ文字列付きのURIはデフォルトでは許可されていません。残るはセッションなんですが、果たしてページングのために検索条件ごときをセッションデータとして保持するという実装は正しいものなんでしょうか?

もしこれが標準的なごく当たり前のプログラム作法?ならば、一言どこかに書いておいて欲しかったなぁとおもうわけです。プロの方はいざしらず素人はそういうことがわからないわけです。つまりこの問題に限らず、なんか実装できるし動いてるかもしれないけどこれってこんなんでいいの?的なことってなかなか情報がないんですよね。

せめてページネーションクラスのところに一言
検索結果等をページングする場合はセッションを使用し検索条件等を保持するようにします

とだけ書いておいて欲しかった。それだけで素人は安心するんです。それだけでセッション機構というなにやら複雑そうな&大袈裟そうなモノにも手を出してみようか?と考えられるんです。

以上長い愚痴でした(でもハマッたんです)。
ラベル:愚痴 codeigniter
posted by ciallost at 22:57| Comment(3) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

CodeIgniterでページングを実装してみる

前回はActiveRecordクラスを使ってまがりなりにも検索できるところまで書きましたが、今回はいよいよ懸案のページングを実装してみたいとおもいます。

そもそもフレームワークを使ってみようとおもったきっかけは、元の手続き型で書いた汚いスクリプトにSQLのLIMITを使ったページングを実装したい、というのが主な動機だったので、これがうまくいけば個人的にはほぼ満足というか目標達成!みたいなところでもあります。

で、さっそくユーザガイドを丸写しっと。まずControllerのIndex()の先頭付近に(別に先頭じゃなくてもいいとはおもいますが…)
$this->load->library('pagination');

としてページネーションクラスをロードします。

次にページネーションに渡すパラメータを設定します。ユーザガイドには最低限3つの項目とありますのでその通りに…
$config['base_url'] = 'http://localhost/codeigniter/index.php/gappei/index/';
$config['total_rows'] = (とりあえず保留);
$config['per_page'] = '10';

total_rowsはDBから総件数を取得してこないとならないのでとりあえず保留。per_pageはゆくゆくはユーザが指定できるようにしたいですが、これもとりあえず10件で、base_urlは“完全なURL”と書いてありますが、例えばうちの設定だとhttp://localhost/codeigniter/http://localhost/codeigniter/index.php/gappei/index/が呼び出されるようなルーティング設定にしてますが、この部分には後者の完全なURLを書かないとまずいみたいです。

そしてconfig配列をページネーションに渡します。
$this->pagination->initialize($config);

次にVIEWに生成されたページングリンクを出力するコードを…
$this->pagination->create_links();

次に保留にしておいた総件数の取得ですが、まず前回のクエリを組み立てている部分のコード…
//結果取得SQL
$this->db->select('nid, pref, nc, o.id oid, o.city oc, gdate');
$this->db->from( "(SELECT DISTINCT nid, pref, nc, yomi, gdate
FROM cities
WHERE nc like '%".$data['newcity']."%'
LIMIT 0, 10) AS vn");
$this->db->join('newcities_oldcities j', 'vn.nid = j.newcity_id');
$this->db->join('oldcities o', 'o.id = j.oldcity_id');

普通総件数を取得したい場合は、この後…
$count = $this->db->get()->num_rows();

でよいとおもいますが、前回までで散々述べてきたとおり、欲しい件数は新市町村の数になるので、このままではいけません。

具体的にはFROM句のサブクエリ部分でのLIMITを適用する前の件数が欲しいわけです。なのでまずサブクエリ部分(LIMITの前まで)を抽出してみます。
SELECT DISTINCT nid, pref, nc, yomi, gdate
FROM cities
WHERE nc like '%".$data['newcity']."%'

で、これをActiveRecord風に書き直します。
$this->db->select('DISTINCT nid, pref, nc, yomi, gdate');
$this->db->from('cities');
$this->db->like('nc', $data['newcity']);

db->like()はキーと値を渡してやると自動的にWHERE句を作ってくれます。こういうのはSQL直書きよりもイイですね。

そしてSQLを発行して件数を数えます。
$count = $this->db->get()->num_rows();

で、countの値を先ほど保留にしておいたtotal_rowsに設定します。
$config['total_rows'] = $count;

これで準備ができたのでブラウザをリロードしてみます。

paging.pngこの画像は3ページ目を表示しているところです。クエリーも総件数取得とデータ取得の2回実行されているのがわかるとおもいます。でもよく見るとページングのリンク部分は確かに3という数字がボールドリンクなしになっていて現在の表示ページが3ページ目だということを表していますが、データをみると1ページ目のデータが出力されています。

とおもってよく見るとLIMIT句のoffset値をちゃんと設定してませんでした(--;ここはページネーションで生成されたURLの第3セグメントを入れないとなりません。ということでデータ取得クエリのFROM句の部分を
$this->db->from(  "(SELECT DISTINCT nid, pref, nc, yomi, gdate
FROM cities
WHERE nc like '%".$data['newcity']."%'
LIMIT ".$offset.", 10) AS vn");

に変更します。そしてindex()が引数を取るように…
function index($offset=0)

と書き換えます。

paging2.pngこんどはきちんと3ページ目が表示されているようです。SQLもちゃんとLIMIT 20, 10になっているのがわかるとおもいます。これで一応ページングは実装できた・・・・・かのように見えますが、実は重大な落とし穴があります(--;

続きは次回
ラベル:codeigniter
posted by ciallost at 18:45| Comment(2) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

CodeIgniterのActiveRecordクラス

前回formヘルパーを使ってシンプルなformを作成しましたが、今回はPOSTで受取ったデータを元にクエリを組立てデータを取得してみたいとおもいます。

まずCodeIgniterのActiveRecordクラスですが、これがかなりイイ感じです。何がイイってこんなに緩くて、ってゆーかこれってSQL直書きしてるのと何が違うんでしょうかね〜?まあたぶん素人にはわからない部分で何かすごく高尚なことをやっているんでしょう(たぶんw)。

それはともかく前に晒した我流SQLを詰め込んでいきます。
$this->db->select('nid, pref, nc, o.id oid, o.city oc, gdate');
$this->db->from("(SELECT DISTINCT nid, pref, nc, yomi, gdate
FROM cities
WHERE nc like '%".$data['newcity']."%'
LIMIT 0, 10) AS vn");
$this->db->join('newcities_oldcities j', 'vn.nid = j.newcity_id');
$this->db->join('oldcities o', 'o.id = j.oldcity_id');

$data['query'] = $this->db->get();

テーブル名が若干変わっていますが、これはCakePHPで試行錯誤した時の名残です。Cakeはテーブル名を原則複数形にしなければならず、さらにHABTM関連の関連付けてるテーブルは両方のテーブル名をアルファベット順に並べたモノにしなければなりません。

なのでnewcities_oldcitiesは前のSQLだとjoinedというテーブルだったものと同じです。citiesも前はcityという名前だったMySQLのVIEWです。

で、問題なのはFROM句ですがここはサブクエリを書かないとならないので、よくわかりませんでしたがとりあえずダラダラと全部書いちゃいました。外側のJOIN部分も以前はWHERE句で書いていたものをせっかくなので(謎)JOINで書いてみました。

最後のdb->get()の部分は前は'cities'テーブルを指定していましたが、ActiveRecordクラスを使ってクエリを組み立てているので、パラメータなしでget()を読んでいます。そして結果…
activeRecord.png
う〜む、ちゃんとデータ取れてますね。ちなみに前にstdClass Objectと表示されていた部分は、VIEWで
foreach($query->result() as $row)

としていたところを
foreach($query->result_array() as $row)

にすることで普通の配列になりました。

あとデバッグ用に
$this->output->enable_profiler(TRUE);

とControllerの最後に記述しました。実行されたSQL文が見えるので便利です。データが取れているので大丈夫だとおもいましたが、一応これを見てもちゃんと意図どおりのSQLが組み立てられているようです。LIMITも新市町村の件数で取ってこれているし、もちろん新市町村名で検索も可能です。

次回はいよいよ懸案の?ページングを実装してみたいとおもいます。
posted by ciallost at 03:49| Comment(0) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする