2008年01月09日

日付入力のチェックその6(まとめ)

前回まででようやく日付入力チェッククラスが形になってきたので、今回はこのクラスの簡単なテストをした結果とそれを市町村合併データベースアプリに組み込んだ際のメモ、加えて日付入力チェックということに関して今回考えたところを述べてみたいと思います。

まずクラスのチェックとして、与えられた日付(のようなモノ?)に対する出力の一覧です。
基本形
from年のみ20052005.1.1〜2005.12.31
from年月のみ2005.32005.3.1〜2005.3.31
from年月日2005.3.212005.3.21〜2005.3.21
from年月日 + to日のみ2005.3.4-212005.3.4〜2005.3.21
from年月日 + to年月のみ2005.3.4-2006.42005.3.4〜2006.4.4
from年月日 + to月日のみ2005.3.4-10.212005.3.4〜2005.10.21
from年月日 + to年月日2005.3.4-2005.6.212005.3.4〜2005.6.21
不正な値を含むパターン
from年月
2005.142006.2.1〜2006.2.28
from年月日
2005.3.352005.4.4〜2005.4.4
2005.14.12006.2.1〜2006.2.1
月日2005.14.352006.3.7〜2006.3.7
from年月日 + to日
to日2005.3.4-502005.3.4〜2005.4.19
from月2005.14.4-212006.2.4〜2006.2.21
from日2005.3.35-212005.3.21〜2005.4.4
from月日2005.14.35-212006.2.21〜2006.3.7
from月, to日2005.14.21-502006.2.21〜2006.3.22
from日, to日2005.3.35-502005.4.4〜2005.4.19
from月日, to日2005.14.35-502006.3.7〜2006.3.22
from年月日 + to月日
to日2005.3.4-10.352005.3.4〜2005.11.4
to月2005.3.4-14.202005.3.4〜2006.2.20
to月日2005.3.4-14.352005.3.4〜2006.3.7
from日2005.3.50-10.212005.4.19〜2005.10.21
from日, to日2005.3.50-10.352005.4.19〜2005.11.4
from日, to月2005.3.50-14.202005.4.19〜2006.2.20
from日, to月日2005.3.50-14.352005.4.19〜2006.3.7
from月2005.14.4-10.212005.10.21〜2006.2.4
from月, to日2005.14.4-10.352005.11.4〜2006.2.4
from月, to月2005.14.4-14.202006.2.4〜2006.2.20
from月, to月日2005.14.4-14.352006.2.4〜2006.3.7
from月日2005.14.50-10.212005.10.21〜2006.3.22
from月日, to日2005.14.50-10.352005.11.4〜2006.3.22
from月日, to月2005.14.50-14.202006.2.20〜2006.3.22
from月日, to月日2005.14.50-14.352006.3.7〜2006.3.22
from年月日 + to年月
to月2005.3.4-2006.142005.3.4〜2007.2.4
from日2005.3.50-2006.42005.4.19〜2006.5.20
from月2005.14.21-2006.42006.2.21〜2006.4.21
from日,to月2005.3.50-2006.142005.4.19〜2007.3.22
from月,to月2005.14.21-2006.142006.2.21〜2007.2.21
from月日2005.14.35-2006.42006.3.7〜2006.5.5
from月日,to月2005.14.35-2006.162006.3.7〜2007.5.5
from年月日 + to年月日
to日2005.3.4-2006.10.352005.3.4〜2006.11.4
to月2005.3.4-2006.14.202005.3.4〜2007.2.20
to月日2005.3.4-2006.14.352005.3.4〜2007.3.7
from日2005.3.50-2006.10.212005.4.19〜2006.10.21
from日, to日2005.3.50-2006.10.352005.4.19〜2006.11.4
from日, to月2005.3.50-2006.14.202005.4.19〜2007.2.20
from日, to月日2005.3.50-2006.14.352005.4.19〜2007.3.7
from月2005.14.4-2006.10.212006.2.4〜2006.10.21
from月, to日2005.14.4-2006.10.352006.2.4〜2006.11.4
from月, to月2005.14.4-2006.14.202006.2.4〜2007.2.20
from月, to月日2005.14.4-2006.14.352006.2.4〜2007.3.7
from月日2005.14.50-2006.10.212006.3.22〜2006.10.21
from月日, to日2005.14.50-2006.10.352006.3.22〜2006.11.4
from月日, to月2005.14.50-2006.14.202006.3.22〜2007.2.20
from月日, to月日2005.14.50-2006.14.352006.3.22〜2007.3.7

テストとしてはこれだけでは不十分で、0を明示的に与えた場合や7桁を超える数値、また年(西暦)に関しては上記ではテストしていません。

が、上記の一覧では期待通りの結果ですし、その他も一覧には載せていませんが手動テストの結果などから(今のところ)気期待通りに動いていることが確認できています。

ということでクラスは一応ちゃんと動いているということにして(^^;、これを本体に組み込みます。

まずcheckdate.phpというファイルをControllerフォルダの中に作成しそこに件のクラスを全部書きます。この時にこれまでのテストでは[〜]区切りで日付範囲を返していたperiodクラスのgetDateRange()メソッドを[-]区切りで返すように変更します。
public function getDateRange()
{
:
return $from."-".$to;
}

これは日付検索クラスと整合性をとるためです。そして本体?のgappei.phpでは頭でそれをrequireします。
require_once('checkdate.php');

つぎにsearch()メソッド内で以前は日付チェック関数を呼んでいた部分を次のように書き換えます。
$conditions['gdate'] = $this->_valid_date($conditions['gdate']);

$conditions['gdate'] = preg_replace("/[^0-9.-]/", "", $conditions['gdate']);

この部分ではその他のsearchコンディションのヴァリデートに加えて、数値・ドット・ハイフン以外を削除するだけにします。

そしてDecoratorパターンを用いて日付検索クラスをインスタンス化している部分で、日付チェッククラスを使用します。
if ($conditions['gdate'] <> '')
{
$vDate = new Period($conditions['gdate']);
$conditions['gdate'] = $vDate->getDateRange();

$this->cond = new GdateSearch($this->cond, $conditions);
}

これで一応組み込みは完了(のはず)です。でテストしてみました。
offset_error.png
「2005」とだけ入力してみたらいきなりエラー(--;。行数を確認するとlist()を使ってる部分でした。で、とりあえずブラウザで戻ってみると入力値の変換自体はきちんと行われているようだったので、とりあえずlist()の前に@をつけてエラーを抑制することにしておきました。

これでさしあたってエラーも出ず?日付で検索することが可能になりました。↑のエラーはlist()に渡す前に配列の数を数えるなりすればいいのかとおもいます。もしかしたらlist()の値と配列の要素数が異なる場合の書き方?とかがあるのかもしれませんがその辺はまだ調べていません。

で、6回に渡って書いてきた日付入力のチェックですが、一言で言えば「やはりメンドイ!」という感じです。単純に日付としての妥当性をチェックしてアラートでも出した方がスクリプト的にはすっきりすることでしょう。

この実装では不正な日付を極力自然に?解釈しようと試みましたが、果たしてこれが万人にとって自然かどうか?ということになるとかなり怪しい?というか、そもそも年月日の順で入力すること自体日本のローカルルールだったりもしますし、いずれにしろ日付入力に関しては決定打はないのかなぁ?と感じました。

ただ最終的にはクラスも作りGregorianToJDの代替関数もでっちあげたりで個人的にはいろいろと学ぶところは多かったようにおもいます。次回からはCodeIgniterに戻って(^^;、AJAXを組み込むことを考えていきます。
ラベル:codeigniter PHP
posted by ciallost at 21:42| Comment(36) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2008年01月06日

日付入力のチェックその5

前回、PHPのdatetime系関数からカレンダー関数に書換え、日付の計算にはユリウス日を使うことにしたところ、カレンダー関数のユリウス日を計算するGregorianToJD()はUNIXタイムスタンプを返すmktime()とは違って、不正な日付を修正?(吸収?)してくれませんでした。

そこでGregorianToJD()の替わりに、mktime()のように2005.13.21とか2005.3.35, 2004.3.0,…etc'のような不正な日付でも受け付けるようにModGregorianToJD()という関数を考えてみました。

今回はそのModGregorianToJD()を日付入力チェッククラスに組込んでテストした際のメモです。

組み込んだ先のクラスは日付抽象クラスのYmdクラスです。
abstract class Ymd
{
:
:
function get()
{
return $this->ModGregorianToJD($this->getMonth(), $this->getDay(), $this->getYear());
}

private function ModGregorianToJD($month, $day, $year)
{
:
:
return $JD + $overDay;
}

}

内部的にしか利用しなさそうなのでPrivate functionにして、get()でユリウス日を返している部分でGregorianToJD()を使っていた部分をModGregorianToJD()に書き換えます。

さらにdateToクラスのgetDay()メソッド内で月の最後の日を返している部分も書き換えます。
return date('t', mktime(0,0,0,$this->getMonth,1,$this->getYear));

return cal_days_in_month(CAL_GREGORIAN, $this->getMonth, $this->getYear);

これで不正な日付を渡しても概ね?は正しい範囲を返すようになりました。が!、まだエラーが出る場合がありました(--;。

原因は↑で書き換えたdateToクラスのgetDay()内で使用しているcal_days_in_month()関数で、この関数もGregorianToJDと同じく不正な日付はエラーになってしまいます。

この関数は前回作成したModGregorianToJD()内でも使用していますが、その場合は事前に不正な日付を修正してから渡していたのでエラーにはなりませんでした。

というわけでまずModGregorianToJD()内で月と年を正常な値に変換している部分を切り出してgetValidMonthYear()という別メソッドにしました。

abstract class Ymd
private function ModGregorianToJD(&$m, &$d, &$y)
{
if ($d == 0) { $m = $m - 1; }
if ($m <= 0) { $y = $y - 1; }
if ($m < 0) { $m = 11; }
if ($y <= 0) { $y = 1; }

$valid = $this->getValidMonthYear($m, $y);
$m = $valid['month'];
$y = $valid['year'];

$lastDay = cal_days_in_month(CAL_GREGORIAN, $m, $y);

$overDay = $d > $lastDay ? $d - $lastDay : 0;

if ($d == 0 || $d > $lastDay) { $d = $lastDay; }

$JD = GregorianToJD($m, $d, $y);

return $JD + $overDay;
}

protected function getValidMonthYear($month, $year)
{
$valid['year'] = $year + ($month > 12 ? (INT)(floor($month / 12)) : 0);
$valid['month'] = ($month % 12 == 0) ? 12 : $month % 12;

return $valid;
}

ついでにModGregorianToJD()でロジック的に冗長だった部分を書き直しています。引数は内部的に変更するので参照渡しにしました。また最大値を6桁に制限していた部分は外に出してYmdクラスのコンストラクタ内で処理するような形に変更しました。
function __construct($date)
{
$temp = preg_replace("/[^0-9.]/", "", $date);
$temp = preg_replace("/\.+/", ".", $temp);
$temp = preg_replace("/(^\.|\.$)/", "", $temp);
$temp = explode('.', $temp, 3);

foreach ($temp as $val)
{
$tempDate[] = (INT)substr($val, 0, 6);
}

$this->init($tempDate);
}

次にdateToクラスのgetDay()で年月の値を正常値にしてからcal_days_in_month()を呼ぶように修正しました。
function getDay()
{
if ($this->day == '')
{
if ($this->from->day <> '')
{
return $this->from->getDay();
} else {
return $this->getLastDay();
}
}

return $this->day;
}

private function getLastDay()
{
$valid = $this->getValidMonthYear($this->getMonth(), $this->getYear());
$lastDay = cal_days_in_month(CAL_GREGORIAN, $valid['month'], $valid['year']);

return $lastDay;
}

ここまでの修正でエラーは出なくなりましたが、明示的に0を入力した場合の挙動が微妙に期待通りではありませんでした。これは主にdateToクラス内で値がセットされてない場合の判定を以下のようにしていることが原因とおもわれます。
if ($this->month == '')

そこでいろいろ試行錯誤した結果下記のように書き直しました。
if ($this->month == '')
{
if ($this->month === 0)
:

これでようやく月の値がない場合や明示的に0の場合は期待通りになりましたが、まだ日の値がセットされていない場合が期待通りに動きません。調べてみるとどうやら空の配列を渡しているとおもっていた部分で実は空ではなくArray([0]=>'')のような空の要素一個の配列を渡していました。

そこでYmdクラスのコンストラクタを下記のように修正して、空の配列を渡すように変更しました。

Ymdコンストラクタ
        :
$temp = explode('.', $temp, 3);

if (count($temp) == 1 && $temp[0] == '')
{
$tempDate = array();
} else {
foreach ($temp as $val)
{
$tempDate[] = (INT)substr($val, 0, 6);
}
}
$this->init($tempDate);

これでようやくエラーもなくほぼ期待通りの動作をしているようです(自信はない…)。次回はこのクラスのテストとこれをやっと本体に組み込むところまでのメモを書きたいとおもいます。
ラベル:codeigniter PHP
posted by ciallost at 18:38| Comment(0) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2008年01月05日

日付入力のチェックその4

前回、日付入力チェック用のクラスを書いている過程で、mktime()に依存している部分をなんとかしたいと考えていたところ、PHPのカレンダー関数なるものに到達?しました。

mktime()も含めたPHPのdatetime系の関数は、UNIXエポックの縛りがあるため、1970年を基点として前後およそ70年ぐらいまで(PHP5.1.0以前で環境がWindowsの場合は1970より過去は扱えない)しか扱えません。

これは要するに時間(時分秒)までを含めて32ビット整数の範囲で扱おうとしていることによる縛りで、今回の場合のように時間を扱わず年月日だけの場合は、カレンダー関数の方が広範囲の年月日を扱うことができるということです。

そこで早速これを使って書き直そうとしたのですが、いきなり壁?というか仕様だからしょうがない?というか、にぶち当たりました。

この日付入力チェックでは前述したとおり[2005.3.4-2006.10.21]のように範囲指定で入力されることを基本形として想定していますが、これが[2006.10.21-2005.3.4]のように前後の日付の関係が前のものより後ろの方が過去になる場合というのも考慮しています。

mktime()に依存した前のバージョンでは、これらの日付を一旦UNIXタイムスタンプに変換してから比較して、前後関係がおかしい場合(前のものより後ろの方が過去)場合は入替えています。

で、カレンダー関数で書き換える場合は、UNIXタイムスタンプに相当するものとしてユリウス日(PHPの日本語訳マニュアルではユリウス積算日)を使います。

ユリウス日(通日・積算日)というのは、紀元前4713年1月1日からの通算の日付ということらしいのですが、桁数が多くなりすぎるから修正ユリウス日という一定数を引いたものを使ったりとかいろいろあるみたいです。

んで、ここでは単に日付を比較できればいいわけで、あまり難しいことは考えずに、カレンダー関数のGregorianToJD()という日付をユリウス日に変換してくれる関数を使ってみました。
return GregorianToJD($this->getMonth(), $this->getDay(), $this->getYear());

ところが通常の入力値、つまり月が1〜12までで日が1〜31の場合はこれでよいのですが、例えば[2006.13.10]とか[2006.5.35]などの場合にエラーになってしまいます。

mktime()の場合は↑のような不正?な日付も勝手に変換してくれていたわけです。
2006.13.10 -> 2007.1.10
2006.5.35 -> 2006.6.4

このmktime()の機能?はかなり便利で、後述しますが0が入力された場合でもよきに取り計らってくれます。
2006.0.5 -> 2005.12.5
2006.5.0 -> 2005.4.30
2004.3.0 -> 2004.2.29

最後の例のように閏年もきちんと考慮してくれます。

ですが前述のとおりmktime()はUNIXエポックの縛りがあるためあまり使いたくありません。まあここでの市町村合併データベースでは範囲的には全く困らない(今のところ)のですが、2038年以降にはまずいわけで(^^;(あと30年もありますが…)まあとにかく今更mktime()に戻るわけにはいかないのです。

というわけでユリウス日変換の際に不正な日付を受け付けるようなModGregorianToJD()というメソッドをでっちあげることにしました。
function ModGregorianToJD($month, $day, $year)
{
/***** 6桁までに丸める(7桁の数値でエラーが出るため) *****/
$y = (INT)substr($year, 0, 6);
$m = (INT)substr($month, 0, 6);
$d = (INT)substr($day, 0, 6);

/***** 溢れた日数:初期化 *****/
$overDay = 0;

/***** 日が0の場合は先月(月が0,-1になる可能性がある) *****/
if ($d == 0) { $m = $m - 1; }

/***** 月が0,-1の場合は昨年(年が0になる可能性がある) *****/
if ($m <= 0) { $y = $y - 1; }

/***** 月が-1の場合は11月(これ以上戻る可能性はない:YYYY/0/0 => YYYY/11/30) *****/
if ($m < 0) { $m = 11; }

/***** 月が12より大きい場合12で割って切り捨てて年に足す(月の不正値:13月〜) *****/
$y = $y + ($m > 12 ? (INT)(floor($m / 12)) : 0);

/***** 12月に納まるように12の剰余を月に設定(0の場合は12) *****/
$m = ($m % 12 == 0) ? 12 : $m % 12;

/***** 年が0以下(紀元前または西暦0年(存在しない))の場合は強制的に西暦1年に設定 *****/
if ($y <= 0) { $y = 1; }

/***** 設定された年月からグレゴリオ暦での月の最後の日を取得 *****/
$lastDay = cal_days_in_month(CAL_GREGORIAN, $m, $y);

/***** 日が0の場合は上で求めた月の最後の日を日に設定 *****/
if ($d == 0) { $d = $lastDay; }

/***** 日が月の最後の日を超える場合 *****/
if ($d > $lastDay)
{
/***** 元の日から月の最後の日を引いて溢れた日数を取得 *****/
$overDay = $d - $lastDay;

/***** 日に月の最後の日を設定 *****/
$d = $lastDay;
}

/***** ユリウス積算日取得 *****/
$JD = GregorianToJD($m, $d, $y);

/***** 溢れた日数をユリウス積算日に足して返す *****/
$newJD = $JD + $overDay;

return $newJD;
}

基本方針としては元のGregorianToJD()に渡しても大丈夫なように年月日を修正してから変換し、溢れた日数は後から足しているというだけです。

一応0も考慮しています。また閏年はcal_days_in_month()という関数できちんと計算してくれます。

冒頭で入力値を6桁までに限定している部分は、テストしてみたら7桁の数値でGregorianToJDがエラーになった(マニュアルにはBC.4714〜AD.9999となっています)ので、さしあたって6桁にしておきました。ただこの部分はこのメソッド内に書くべきことではないような気もします。

次回はこれを日付入力クラスに組み込みます。
ラベル:codeigniter PHP
posted by ciallost at 18:45| Comment(0) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

日付入力のチェックその3

前回、日付入力チェック用のクラス(の雛型?)を作成しましたが、今回はそれをマトモなものに修正していきます。

まずPeriodクラス,Ymdクラスのコンストラクタに入力チェック?というか整形?用のコードを付け足します。

Periodクラス function __construct($gdate)
$temp = preg_replace("/-+/", "-", $gdate);
$temp = preg_replace("(^-|-$)", "", $temp);

連続する[-]ハイフンを1個にして、行頭または行末のハイフンを削除します。

Ymd(日付抽象)クラス function __construct($date)
$temp = preg_replace("/[^0-9.]/", "", $date);
$temp = preg_replace("/\.+/", ".", $temp);
$temp = preg_replace("/(^\.|\.$)/", "", $temp);

数値と[.]ピリオド以外は削除して、連続ピリオドを1個に、行頭・行末のピリオドを削除します。

次に日付のtoクラスの方ですが、今までのところでは値がセットされていない場合はfromと同じように1を返していましたが、これを次のようなルールで適当な値を返すように書き換えます。

・年の値がない場合は、fromの年
・月の値がない場合は、fromの月があればそれ、なければ12
・日の値がない場合は、fromの日があればそれ、なければ月の最後の日

というようにしたいのですが、いずれにしろtoからfromに問い合わせる必要があるため、まずコンストラクタを書き換えてfromへの参照を保持するようにします。
class dateTo extends Ymd
{
private $from;

function __construct($date, Ymd $from)
{
$this->from = $from;
parent::__construct($date);
}
:
:
}

この変更に伴いPeriodクラスからdateToを生成している部分を書き換えます。
$this->from = new dateFrom($from);
$this->to = new dateTo($to, $this->from);

先にfromのインスタンスを生成して、toを生成するときにはそれを渡します。

尚、前回は端折っていましたが、Ymdクラス(dateFromとdateToの親)でメンバを初期化するinit()を定義し、それぞれの子クラスで実装します。
abstract class Ymd
{
protected $year;
protected $month;
protected $day;

function __construct($date)
{
$temp = preg_replace("/[^0-9.]/", "", $date);
$temp = preg_replace("/\.+/", ".", $temp);
$temp = preg_replace("/(^\.|\.$)/", "", $temp);
$temp = explode('.', $temp, 3);
:
:
$this->init($tempDate);
}

abstract function init(array $date);
:
:
}

dateFromクラス
function init(array $date)
{
list($this->year, $this->month, $this->day) = $date;
}

dateToクラス
function init(array $date)
{
switch (count($date))
{
case 1:
$this->day = $date[0];
break;
case 2:
if ($date[0] <= 1900)
{
list($this->month, $this->day) = $date;
} else {
list($this->year, $this->month) = $date;
}
break;
case 3:
list($this->year, $this->month, $this->day) = $date;
break;
}
}

dateFromの方は単純に配列をlist()でセットしているだけです。この場合値の数が足りない場合は単にセットされないだけです。つまり年月日の順で値がセットされていくということです。

dateToの方はdateFromとはほぼ逆で、値が1個の時はそれを「日」とみなします。値が3個全部揃っている場合はdateFromと同じですが、値が2個の時は1個目の値に閾値を設けて、それより大きな場合は「年月」とし小さい場合は「月日」とみなすことにします。閾値はとりあえず適当に1900としました。

そしてdateToの年月日それぞれのゲッターですが、↑のルールに則って次のように書きました。
function getYear()
{
if ($this->year <> '')
{
return $this->year;
} else {
return $this->from->getYear();
}
}

function getMonth()
{
if ($this->month <> '')
{
return $this->month;
} else {
if ($this->from->month <> '')
{
return $this->from->getMonth();
} else {
return 12;
}
}
}

function getDay()
{
if ($this->day <> '')
{
return $this->day;
} else {
if ($this->from->day <> '')
{
return $this->from->getDay();
} else {
return date('t', mktime(0,0,0,$this->getMonth,1,$this->getYear));
}
}
}

で、これでだんだんと希望のものに近づいてはきているのですが、一番の問題はmktime()に依存している部分で、これをどうにかできないか?と考えて…いやPHPのマニュアルを見ていたところ、カレンダー関数なるものを見つけました。

で、UNIXタイムスタンプの縛りがないということが判明。急遽そっちを使うことにしました。
ラベル:codeigniter PHP
posted by ciallost at 01:42| Comment(0) | TrackBack(1) | 日記 | このブログの読者になる | 更新情報をチェックする

2008年01月04日

日付入力のチェックその2

前回に引き続き日付入力のチェックを考えてみます。今回は前回の課題?であった感覚的な入力(期待値)との差異という部分をなんとかしてみたいとおもいます。

まず前回定めたルールのうち1〜4まではそのままで問題ないとおもいますが、5.の年月日のどれかが省略された場合は1を補うというルールおよび6.の不正な値はmktime()に任せるという2つのルールをもう一度練り直してみます。

5.のルールは単一の年月日だけならばよいのですが、範囲指定した場合の後のほうの年月日にこれを適用してしまうと、感覚的にちょっと違和感があります。例えば…
2005.04.06-10.25

のような入力の場合、現状だと…
2005.04.06-2012.01.01

になります。

これは後のほうの年月日が10.25なので年が省略されたとは解釈されずに、10を西暦と解釈しようとします。ここで6.のルールのmktime()の仕様で、2桁の西暦は0-69の間の値は2000-2069に70-100は1970-2000にマップされ、従ってまず年は2010となります。

さらに次の25は月と解釈され25=12*2+1で2年繰り上がり年が2012年になり月は1月になります。そして5.のルールで日が空なので1日に設定され上記のような結果になります。

しかし感覚的には(個人的にはですが)
2005.04.06-2005.10.25

になって欲しい。おそらくほとんどの人がそう考えるだろうとおもいますが???(まあいろんな人がいるのでわかりませんが…)とにかく自分は↑のようになってくれたらとおもいました。

んで、ここには2つの問題があります。1つは10.25を自動的に頭から年月日と解釈しようとしていること、もう1つは仮に年月ではなく月日と解釈されたとしても、今度は年が空なので1が補われ結果的に2001.10.25のようになってしまうということです。

そこでまず年月日を頭から解釈していくという部分をなんとかしなければなりません。但しこれは範囲指定の後ろの方の年月日に関してということになります。前の方の年月日は頭から順に解釈していっても特に問題はないようにおもいます(今のところ)。

次に月日と解釈した後に年が空になりますが、ここで自動的に1を補ってしまうというのも考えものです。↑の例では2005、つまり前の方の年月日と同じ年に設定したいわけです。ただしこのルール?も適用したいのは後ろの方の年月日だけで、前の方の年月日は1を補う形で問題ないとおもわれます(今のところ×2)。

このあたりまで考えてみると、どうも前の方の年月日と後ろの方の年月日の処理は分けた方がよさそうな気がしてきます。さらに現状では範囲指定なしの、つまり単体の年月日指定というのも許可していますが、これは煩雑になりそうなので、たとえ年月日が一つしか入力されなくても結果として設定されるモノは範囲指定にした方がすっきりいくような気がします。そんなわけで、ここはやはりclassを作ることにしました。

基本設計?としては年月日オブジェクトというものを想定して、範囲指定なのでそれぞれdateFromクラス,dateToクラスのように範囲指定の[〜]の前をfrom、後をtoというように2つのインスタンスを作ります。さらに範囲指定オブジェクト?を作り年月日オブジェクトはそこから生成するようにします。またdate*クラスはfromとtoで微妙に挙動が違うことが予想されるので大元の抽象クラスを作りそこから継承するようにします。
class Period
{
private $from;
private $to;

function __construct($gdate)
{
$temp = explode('-', $gdate);

list($from, $to) = $temp;

$this->from = new dateFrom($from);
$this->to = new dateTo($to);
}

public function getDateRange()
{
$jd[] = $this->from->get();
$jd[] = $this->to->get();

sort($jd);

$from = date("Y.m.d", jd[0]);
$to = date("Y.m.d", jd[1]);

return $from."〜".$to;
}
}

abstract class Ymd
{
protected $year;
protected $month;
protected $day;

function __construct($date)
{
$temp = explode('.', $date);
}

function get()
{
return mktime(0, 0, 0, $this->getMonth(), $this->getDay(), $this->getYear());
}

abstract public function getYear();
abstract public function getMonth();
abstract public function getDay();
}

class dateFrom extends Ymd
{
function __construct($date)
{
parent::__construct($date);
}

function getYear()
{
if (!isset($this->year)) {return 1;}
return $this->year;
}

function getMonth()
{
if (!isset($this->month)) {return 1;}
return $this->month;
}

function getDay()
{
if (!isset($this->day)) {return 1;}
return $this->day;
}
}

class dateTo extends Ymd
{
function __construct($date)
{
parent::__construct($date);
}

function getYear()
{
if (!isset($this->year)) {return 1;}
return $this->year;
}

function getMonth()
{
if (!isset($this->month)) {return 1;}
return $this->month;
}

function getDay()
{
if (!isset($this->day)) {return 1;}
return $this->day;
}
}

だいたいの概観?は上記の通りです。呼び出し側からはPOSTで受取った値を適宜入力チェック&整形してperiodオブジェクトを生成し、getDateRange()を呼び出す、という流れです。

$gdate = trim(preg_replace("/[ \s]+/u", ' ', $_POST['gdate']));
$gdate = preg_replace("/[^0-9.-]/", "", $gdate);
$vDate = new Period($gdate);
$valid_date = $vDate->getDateRange();

[.]ドットと[-]ハイフン区切りの日付データを受取ったPeriodクラス(オブジェクト)は、ハイフンで範囲指定の前半の日付(from)と後半の日付(to)を分けて、Ymd(日付抽象クラス)を継承したそれぞれのクラスのインスタンスを生成します。

日付クラスではドットで年月日を分けてメンバに持つようにして、あとはgetメソッドで適宜返すようにします。上記では今のところ、設定されていない場合は1を返すように書いてあります。

PeriodクラスのgetDateRange()はそれぞれのdateクラスのget()を呼び出して、ソート&ドット区切りの日付に整形して返します。dateクラスのget()メソッドは親クラス(Ymd抽象クラス)で定義してあり、mktime()でUNIXタイムスタンプを返すようにしてあります。

とりあえずこんな感じで前回の動作を再現することができたので、次回からはこのクラスに手を入れて感覚的な入力との差異を解消していく方向で考えてみます。
ラベル:codeigniter PHP
posted by ciallost at 20:14| Comment(0) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年12月16日

日付フィールドの入力チェック

前回はDecoratorパターンもどき?で合併日の日付をその他のフィールド(都道府県、新市町村、旧市町村)と共にAND検索することができるようになりました。今回はその日付フィールドの入力チェックのメモです。

当初からこれはかなり複雑になることが予想されていましたが、そもそも何故そうなるのか?というと、これは個人的なUIの趣味?というか、要するに日付の入力を単体のフィールドでしかも範囲指定までするという仕様が一番の原因です。

個人的な趣味というのはどういうことか?というと、煩いUIが嫌なんです。入力チェックというと範囲外の入力があった場合にアラートがドンッ!と出てきて「違うぞゴルァ!・・・[OK]」みたいなのがありがちですが、アレほど使い勝手の悪いUIもないとおもいます。

Webアプリとかだと大抵はjavascriptのalert()かなんかで、上記のようなエラーメッセージ?が表示されたりするわけですが、こういうのはまあ百歩譲ったとしてもせいぜいデータベースにデータを入力(INSERT)するような、つまりバックエンドのRDBのデータが変更されるような時ぐらいはまあヨシとして、ここでやろうとしてるようなたかだか検索程度のことでアラートが出てきた日にはもう二度とそのアプリは使いたくなくなるでしょう。

そんなわけで極力そういうキモイUIは避けたく、入力チェックといってもスクリプトに渡す値が適切かどうか判断して、範囲外だった場合は適宜修正してから当該スクリプト(のメソッドとか)に渡す、という方式が好みだったりします。

ところがこれが単純な数値とか五十音パッドのような平仮名だけのように比較的限定範囲が明確なものならいいのですが、日付(しかも範囲指定)となるとなかなか容易ではありません。

容易ではなくなる要因のひとつには、ルールを単純化してしまうと感覚的な入力との差異が際立ってしまうというのがあります。例えば今回のように範囲指定を入力させる場合、2007.03.21-2007.04.15のようにフォーマットに則って正しく入力されればいいのですが、2007.03.21-04.15のように西暦部分が省略された場合など、人間が見れば「あ〜これは後半部分の2007が省略されているな」とすぐわかりますが、プログラム的には非常に面倒くさいことになります。

また日付というのは数字を使って表現されていて一見プログラミングと相性がいいような気もしますが、ご承知のとおりこれがかなりアナログチックな代物で、定型的に?処理しようとすると恐ろしく複雑になっていきます。そもそも0という概念がないし…。

で、愚痴はともかく、普段ならテキトーに誤魔化すところですが、今回はこうした複雑な部分をできるだけスクリプトで実現してみたい?などと考えてしまいました。といってもいきなり複雑なモノを作ろうとすると難しいので、まずはたたき台的に単純なルールでやってみました。

ルール(単純版)

1.日付は年月日(西暦)を[.]ドット区切りで表現する 2007.12.08
2.月日が一桁の場合は桁合わせの0は省略可
3.範囲指定は[-]ハイフンで繋げる 2007.3.21-2007.12.8
4.範囲指定は省略可(2個目の年月日はなくてもよい)
5.年月日のどれかが省略された場合は1を補う 2007.10->2007.10.1
6.不正な値はmktime()に任せる 2007.10.32->2007.11.1

たったこれだけですがスクリプト的にはなにやら複雑な感じになっています(--;。最後のmktime()や桁合わせ0省略ルールはPHPに100%依存しています。んで、とりあえずできあがったのがこれ↓
function _validDate($gdate)
{
//[.]ドットは年月日の区切り、[-]ハイフンは範囲指定
$Ymds = preg_replace("/[^0-9.-]/", "", $gdate);

//2個以上の[-]ハイフンは無視して配列に展開
$Ymd = explode('-', $Ymds, 2);

foreach ($Ymd as $date)
{
$tmpD = preg_replace("/[^0-9.]/", "", $date);
$tmpD = preg_replace("/\.+/", ".", $tmpD);

if ($tmpD <> '')
{
$tmpD = explode('.', $tmpD);
}

while (count($tmpD) < 3)
{
$tmpD[] = 1;
}

list($year, $month, $day) = $tmpD;

$timestamp[] = mktime(0, 0, 0, $month, $day, $year);
}

sort($timestamp);

foreach ($timestamp as $time)
{
$formattedDate[] = date("Y.m.d", $time);
}

$fDates = implode('-', $formattedDate);

return $fDates;
}

まず数値・ドット・ハイフン以外を問答無用で削除、ハイフンでexplodeします。explodeの3個目のパラメータは生成された配列の最大個数です。つまり3個以上にはなりません、また行頭にハイフンがあった場合は1個目の要素が空になります。

次のforeachブロックの最初のpreg_replaceはたぶん余計ですね、なんとなく心配性?(現実世界では全然心配性ではないが…)的に書いちゃいました。その次のpreg_replaceで連続ドットを1個にしています。これは「月」にあたる要素が空白だった場合「日」を前に詰めてしまうことになりますが、[..............]のような極端な場合を想定して、一応これも書いておきます。↓のlist()で4個目以降の要素があっても弾かれるので、この行もたぶん要りません。

次のif文とwhileブロックで空だろうとなんだろうととにかく要素3個(以上)の$tmpD配列を作ります。要素が空の場合は1で埋めています。そしてlist()で年月日変数に割り当てます。ここで4個目以降の要素があった場合でも無視されます。

次にそれをmktime()に渡してUNIXタイムスタンプ配列を得ます。この時点で13月25日とか5月48日のような不正な日付が解消されます。で、2個の要素のタイムスタンプ配列をソートしていますが、これは2個目が1個目より過去の場合を想定していて、SQL生成の際におかしなことになりそうなので必ず「古い日付〜新しい日付」になるようにします。

最後にタイムスタンプを再びdate()でドット区切りの日付に戻し、ハイフンで繋げて返しています。

これで一応最初に作ったルールには合致しているようです(もしかした穴があるかも???)。ルール2はmktime()を通している時点で勝手にIntegerにキャスト?されているとおもいます。

んで実際動かしてみると、まあ書式に則って入力した場合はいいとして、エラーはでないものの↑の感覚的な入力との差異という部分が気になってきます。例えば…

2007.5.21-6.30 =>
 2007.05.21-2007.06.30 となって欲しいところですが、
 2007.05.21-2008.06.01 となりますし、

2007.05-09 =>
 2007.05.01-2007.09.30 か、せめて
 2007.05.01-2007.09.01 と期待したいところですが、
 2007.05.01-2009.01.01 となってしまいます。

もちろんこれはプログラム的には正しい動作です。しかしどうしても感覚的には前者の方がしっくりきます。また…

2005 とだけ入力した場合は、
 2005.01.01-2005.12.31 になって欲しかったり、

2004.02 なら
 2004.02.01-2004.02.29のように閏年も考慮した上で範囲を返してくれたらいいなぁ?…ともおもいます。

さらにmktime()を使用したことでいわゆるUnixエポック(1970年1月1日 00:00:00 GMT)の縛り?が生じてしまいます。このアプリでは実際使われている日付データの範囲は2001年〜2007年まででしかないので、問題ないと言えばそうなのですが、範囲外の入力値があった場合に1970.01.01などとなるのは若干気持ち悪い気もしないでもありません。

2005.06.30-50.22 => 1970.01.01-2005.06.30

50.22という値はもはや日付というより謎の数値ですが、これを「月」とみなして、

2005.06.30-2009.02.22

のように変換されてもいいのではないか?…と。

まあそんなわけでとりあえず単純版のルールではなんとか実装できたっぽいので、次回は後半述べたような感覚的な入力との差異の吸収ということに関して考えてみたいとおもいます。
ラベル:codeigniter PHP
posted by ciallost at 04:49| Comment(0) | TrackBack(1) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年12月10日

検索機能の強化:日付の検索その1

前回は同一検索フィールド内でのAND検索の実装のメモを書きましたが、今回は日付の検索機能の実装をメモっていきます。

このアプリでは市町村の合併日フィールドをキーに検索できるようにしたいのですが、これがかなり後回しになったのは正直メンドイからです(^^;。日付というある意味扱いにくいデータ?なこともさることながら、これまでの検索関係の実装に加えて合併日検索?を盛り込もうとしたときに、正直どのように書けばいいのか?迷ってしまったわけです。

まず一般的に日付データ?というか日付入力が扱いにくいのは、ネット上でも言及され尽くしている感がありますが、要はプログラム(マー)の意図した書式で如何にユーザに入力させるか?という部分で、これに関しては様々な方法がありUIの進化の過程?も垣間見ることができます。

テキストフィールド
 ↓
年月日個別のテキストフィールド
 ↓
年月日個別のプルダウンメニュー
 ↓
カレンダー

みたいな感じで進化してきているようです。おそらくは当初は単なるテキストフィールドで2007/12/08のように完全にフォーマットに従って入力させようと試みていたのが、区切り文字にバリエーションを持たせたり(2007-12-08)とか、区切りなし(20071208)で入力したいとか、0で埋めたくない(2007/12/8)とか…いろいろな要望にこたえる形でのこととおもわれます。

また意図しない書式で入力された場合の入力チェックの煩雑さというのも、このような進化に影響を与えていることとおもわれます。で、最近?はカレンダーでクリックするやつが流行ってる?ようですが、この方法は入力チェックの観点からすると楽といえば楽です。ただ個人的には日付入力ごときでカレンダーのような大袈裟な過去のアナログ時代の遺物みたいなものが出てくるのがどうしても我慢できなくてこの方法は採りたくなかったわけで…。理想的にはテキストフィールド一発でやりたい!というのがまず一つ目のメンドイ原因です。

メンドイ理由の2つ目は、検索オブジェクトなるモノを作ってしまった結果、無理矢理にでもオブジェクト指向的にならざるを得ず、かといってそんなの知らないよ!状態だったので、まずはその辺から勉強しなきゃならなくて時間はかかるし不安は残るし…で。結局なんちゃってデザインパターンのようなモノを書いたわけですが、たぶん1週間後にはきれいさっぱり忘れそうなのでまずはそれからメモっておきます。

まず合併日で検索ということでViewのformにテキストフィールドを追加します。

View/serach_form.phpに合併日フォーム追加
$inputFieldName = array('pref', 'nc', 'oc', 'gdate');
合併日<br />
<?=form_input($input['gdate'])?>

$inputFieldNameはテキストフィールドのname属性を配列にして処理しているので、最初に名前の配列を生成しています。その配列に新たにgdateを加えています。

とりあえずこれだけで合併日の検索?はできるようになります。が、これは以下のようなlike節を生成しているだけで、つまりは合併日フィールドを文字列として検索していることになります。
WHERE gdate like '%2007-12-08%'

また、このままでは検索タイトル(検索結果ページの上部に「新市町村:〜」の検索結果、のように表示している部分)を出力している部分のコードでエラーが出てしまいます。なので検索オブジェクトクラスファイルのNormalSearchクラスで検索タイトル(HTMLで表示するための文字列)を取得するgetSearchTitle()内のswitch文で分岐している部分に、新たに合併日(case 'gdate')を追加します。
function getSearchTitle()
{
$searchTitle = '';

foreach ($this->getCriteria() as $key => $word)
{
switch ($key) {
case 'pref':
$searchKey = '都道府県';
break;
case 'nc':
$searchKey = '新市町村';
break;
case 'oc':
$searchKey = '旧市町村';
break;
case 'gdate':
$searchKey = '合併日';
break;
}

$searchTitle .= "「".$searchKey.":".$word."」";
}

$searchTitle .= 'の検索結果';

return $searchTitle;
}

この部分のコードは元々あまり綺麗でなく自分でもなんとかしようとは思っているのですが、とりあえずはここに「合併日」を追加します。

これでエラーは出なくなりましたが、文字列検索ではどうしようもないので、ちゃんと日付として範囲検索などもできるように考えてみます。まず入力フォーマットをでっちあげ決めます。

・日付はYYYY.MM.DDのドット区切り
・範囲指定は[-]ハイフン
ex:2005.04.01 又は 2005.04.01-2005.12.31

たったこれだけなんですが、入力する際に完全に↑のフォーマットに従って入力されればよいものの、想定される範囲で考えても入力チェックはかなり複雑になりそうな予感がします。ま、とりあえずは「数値、[.]ドット、[-]ハイフン」以外は無条件で削除することにして、それをcontrollerに書きます。

Controller/function search()
//[.]ドットは年月日の区切り、[-]ハイフンは範囲指定
$conditions['gdate'] = preg_replace("/[^0-9.-]/", "", $conditions['gdate']);

日付のデリミッタを[.]ドットにしたのは、範囲指定の記号に[-]ハイフンを使いたかったからです。ただこのため現状の合併日フィールドの書式(YYYY-MM-DD)と異なってしまうために、これをドット区切り表記に変更することにしました。

データ取得クエリを生成している部分の外側のSELECTでフィールドを列記している部分を以下のように変更します。
SELECT ... gdate ...

SELECT ... DATE_FORMAT(gdate, '%Y.%m.%d') as gdate ...

MySQLのDATE_FORMAT関数を使って変換しているだけです。注意しなければならないのは、データ取得クエリにはFROM句でサブクエリを使用していますが、その部分やWHERE句では上記の変換をせずに、外側のSELECTに適用するということです。
SELECT〜
FROM (
SELECT〜
WHERE〜
)
WHERE〜

↑のような構造の?SQLの場合
内側のWHERE句>内側のSELECT>外側のWHERE句>外側のSELECTの順に解釈される?とおもわれ、仮に内側のSELECT部分などにDATE_FORMAT()を使用してしまうと、gdateをWHERE句に用いた場合にデータを取ってこれなくなってしまいます。

model/function getData()
//結果取得SQL
$this->db->select("pid, pref, nid, nc, yomi, oid, oc, oyomi, gdate");

$this->db->select("pid, pref, nid, nc, yomi, oid, oc, oyomi,
DATE_FORMAT(gdate, '%Y.%m.%d') as gdate");

これで合併日が(YYYY.MM.DD)の形式になりました。

で、ここからが本題なのですが、formからのテキストフィールドに入力された値をキーに検索する時はNormalSearchクラスの検索オブジェクトクラスがインスタンス化されています。これは五十音パッドの検索部分の実装(多態編)で作成したとおりなのですが、searchModeという抽象クラスを継承したそれぞれの検索クラスがあるわけです。
               searchMode
  ┌──────┼─────┐
NormalSearch CapSearch IdSearch

このような形になっていると、検索機能の強化:IDで検索できるようにしてみるで述べたように、新たに検索機能?を付け加える際にはsearchModeを継承して新しいクラスを作ればいいようになっています。

ところが今回のように合併日を検索しようとする場合、formが一緒になっていることからもわかるように、合併日を単体で検索するだけでなく都道府県、新市町村、旧市町村などと組み合わせてAND検索をしたいわけですが、上記のクラス構成でsearchModeを継承して新たにGdateSearchなどというクラスを作ってしまうと、NormalSearchと組み合わせた検索?ができなくなってしまいそうです。(※そのような構成でも組み合わせる方法があるのかもしれませんが???)

そこでいろいろ調べた結果Decoratorパターンを使えばなんとなくいいような気がしてきたので、見よう見真似でそれを実装してみることにしました。ちなみにその際に下記のサイト様を参項にさせていただきました。

まさるblog 越後在住ソフトウェアエンジニアの記録
デザインパターンを学ぶ〜その6:Decoratorパターン(1)〜


koshigoewiki:php:デザインパターン:decoratorパターン

あとデザインパターン全体に対しての下記サイト様の言及が非常に参考になりました。

Happie's page SE・PG入門 デザインパターンを読み解く

で、Decoratorパターンですが、自分が理解したところをぶっちゃけて言ってしまえば、これはいわゆるラッパーで、デコレートされるクラスと共通の抽象クラスを継承しているデコレートクラスを作り、呼び出し側では元クラスの替わりにそのデコレートクラスを呼び出す、といった感じだと思います。その際デコレートクラスではメンバに元クラスを保持するようにして、メソッド内ではデコレートする処理と共に元クラスのメソッドも呼び出すような格好で実装?する…みたいな感じでしょうか???

まあ詳しい説明&正しい説明は他のちゃんとしたサイトを参照してもらうとして、コードだけ晒しておきます。(このエントリかなり長くなっちゃったし…)

検索オブジェクトクラス(search.php)
abstract class SearchMode
{
private $criteria;

function __construct(array $condition)
{
$this->criteria = $condition;
}

final public function getCriteria()
{
$criteria = array_diff($this->criteria, array('', NULL));
return $criteria;
}

abstract public function where();

abstract public function getSearchTitle();
}

abstract class OtherSearch extends SearchMode
{
protected $searchMode;

function __construct($searchMode, $condition)
{
parent::__construct($condition);
$this->searchMode = $searchMode;
}
}

class GdateSearch extends OtherSearch
{
public function __construct($searchMode, $condition)
{
parent::__construct($searchMode, $condition);
}

function where()
{
$CI =& get_instance();

$criteria = $this->getCriteria();
$date = explode('-', $criteria['gdate']);

$CI->db->where('gdate >=', $date[0]);
$CI->db->where('gdate <=', $date[1]);

$this->searchMode->where();
}

function getSearchTitle()
{
$criteria = $this->getCriteria();
$newSearchTitle = "「合併日:".$criteria['gdate']."」の検索結果";
$oldSearchTitle = $this->searchMode->getSearchTitle();

$searchTitle = str_replace("の検索結果", $newSearchTitle, $oldSearchTitle);

return $searchTitle;
}
}

class NormalSearch extends SearchMode
{
function __construct(array $condition)
{
parent::__construct($condition);
}

function where()
{
$CI =& get_instance();

foreach ($this->getCriteria() as $field => $condition)
{
if ($field == 'gdate') {continue;}

$sameFieldConditions = explode(" ", $condition);

foreach ($sameFieldConditions as $cond)
{
$CI->db->like($field, $cond);
}
}
}

function getSearchTitle()
{
$searchTitle = '';

foreach ($this->getCriteria() as $key => $word)
{
if ($key == 'gdate') {continue;}

switch ($key) {
case 'pref':
$searchKey = '都道府県';
break;
case 'nc':
$searchKey = '新市町村';
break;
case 'oc':
$searchKey = '旧市町村';
break;
/***** GdateSearch内で処理 *****/
}

$searchTitle .= "「".$searchKey.":".$word."」";
}

$searchTitle .= 'の検索結果';

return $searchTitle;
}
}

このファイル内には他にCapSearchクラスとIdSearchクラスが書いてありますがそれは省略してあります。新たに付け加えた部分はOtherSearchとGdateSearchで、OtherSearchはデコレータのインタフェース?でコンストラクタに元クラスのオブジェクトと検索条件を引数とするようにして、上述したようにメンバとして検索オブジェクトを保持します。GdateSearchが今回のメイン?でOtherSearchを継承したデコレータクラス?になります。

where()メソッド内では範囲指定([-]ハイフン区切り)された検索条件からクエリを生成します。当然のことながらこのWHERE句はNormalSearchとは全然違ったモノになります。そして最後に元クラスのwhere()を呼んでいます。CodeIgniterのアクティブレコード的にはdb->where()を連続して呼ぶとANDで連結するようなWHERE句になります。

getSearchTitle()は単純に元クラスのgetSearchTitle()で検索タイトルを取得して、それに「合併日:〜」を付け足しているだけです。ただし「〜の検索結果」という文字列を最後にしたかったので、単純に+するのではなく(↑の方の牛丼の例ではそのような形になっていましたが)str_replaceで該当部分を置き換えています。この結果↑の方で書いたNormalSearchクラスのgetSearchTitle()内でswitch文で分岐していたcase 'gdate'の部分は削除しました。

最後にControllerのsearch()ファンクションでNormalSearchをインスタンス化している部分の後にデコレータとしてGdateSearchをインスタンス化するコードを加えます。
function search()
{


if (implode('', $conditions) <> '')
{
$this->cond = new NormalSearch($conditions);

if ($conditions['gdate'] <> '')
{
$this->cond = new GdateSearch($this->cond, $conditions);
}


}
}

GdateSearchでは上述のように検索オブジェクトと検索条件の2つのパラメータが必要なので、先にインスタンス化されてメンバとして保持されている$condを検索条件$conditionと一緒に渡します。

というわけでなんとか合併日で検索できるようになりました。

例によって表示結果w
gdate_search.png

次回はこのフィールドの入力チェックに関してのメモです。
ラベル:codeigniter PHP
posted by ciallost at 03:22| Comment(0) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年12月07日

検索機能の強化:検索フィールド内でのAND検索

前回に引き続き検索機能の強化ということで、今回は検索フィールド内でのAND検索の実装についてメモしておきます。検索フィールド内でのAND検索というのは、同一フィールド内でスペース(半角スペース)で区切られた単語(この合併データベース検索アプリの場合はほとんど文字と同義)をデータベースに対してANDでフィルタリングしよう、というものです。

具体的には、都道府県フィールドなどに「県[SPACE]山」などと入力した場合に、これまでのモノはWHERE pref like '%県 山%'というクエリしか発行せず結果は0件になりますが、これをWHERE pref like '%県%' AND pref like '%山%'というようなクエリを発行するようにして「山形県」「山梨県」「岡山県」…などを検索できるようにしたい、ということです。

↑でもちらっと触れたように、この機能?はココの合併データベース検索アプリではほとんど必要ないものかもしれません。もしくはANDではなくORにした方がよいのではないか?という気もしないでもありません。が、まあ大元のスクリプトには実装されてることですし、とりあえず作ってみたいとおもいます。

まず入力値ですが、これは次のように規定します。

半角または全角スペースで区切られた単語(文字)をAND検索する
連続する半角・全角スペースは1個とみなす

で、これをそのままヴァリデーションとして実装します。
//pregに/uオプションを付けても、\s(空白文字)に全角SPはマッチしない
foreach ($this->input->post('condition') as $key => $str)
{
$conditions[$key] = trim(preg_replace("/[ \s]+/u", ' ', $str));
}

preg_replaceに/uオプション(unicode対応)をつけてホワイトスペース文字(\s)の連続を半角スペースに置き換えて両端をtrimしています。これをフィールド分(今のところ3つ:都道府県、新市町村、旧市町村)繰り返しているだけです。

但し全角スペースに関してはコメントにあるように、/uオプションでも空白文字としては認識していないようだったので、文字クラス内に記述しました。なので正規表現部分の空白は全角スペース、置換文字の方の空白は半角スペースです。

次に実際のクエリを組み立てている部分ですが、元々は連想配列をアクティブレコードのlike()に渡していた部分を書き換えます。

修正前 controller/search.php(検索オブジェクトクラスファイル)
function where()
{
$CI =& get_instance();

$CI->db->like($this->getCriteria());
}

修正後 controller/search.php(検索オブジェクトクラスファイル)
function where()
{
$CI =& get_instance();

foreach ($this->getCriteria() as $field => $condition)
{
$sameFieldConditions = explode(" ", $condition);

foreach ($sameFieldConditions as $cond)
{
$CI->db->like($field, $cond);
}
}
}

結局単に配列を渡していただけの部分がforeachの入れ子になってしまいました。やってることは見てのとおり単純なことなんですが、修正前と比べるとやはりかなりアレだなぁ?という印象が…。

でもまあ基本的にはこれだけの変更でフィールド内でのAND検索が実装できたわけで、こういう部分は手続き型でダラダラ書いているよりはわかりやすいと感じました。

検索画面
and_search.png
まずこんな感じで適当に半角・全角スペースを混ぜて検索してみます。
※赤枠:全角スペース、緑枠:半角スペース(300%拡大)

表示結果
and_search_result.png

↑のイメージのように、前後のスペースが削除され連続スペースも一つになっているのがわかるとおもいます。クエリも各フィールドごとにANDで出力されているようです。もちろん検索結果も正常です。

ということでおもっていたよりも意外にあっさりとできた感じでした。次回は合併日の検索について書こうとおもいます。
ラベル:codeigniter PHP
posted by ciallost at 22:49| Comment(1) | TrackBack(1) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年12月03日

検索機能の強化:IDで検索できるようにしてみる

前回はMySQLの照合順序で右往左往してしまい、検索機能強化がどんどん後回しになっていたわけですが、今回から(何事もなければ…)何回かに分けて検索機能の強化について書いてみたいとおもいます。

検索機能の強化といっても大したことを考えているわけではなく、いわゆるAjaxでトレンドになったいんくりめんたるさあちとか、全文検索でファジー(死語?)にどーのこーのとかいうような高級なことをやろうとしているわけではありません。

一応ロードマップ?としてはまず今回はIDで検索できるようにスクリプトを改造します。次に検索フィールド内でAND検索ができるようにします。これはグーグルのようにスペースで区切った単語をAND検索するというものです。さらに合併日で検索できるようにします。

以上の検索方法は大元のスクリプトには、もちろん手続き型の例のアレですが、一応実装されていて、CodeIgniter?というかオブジェクト指向的?に実装したらどうなるのかな?という実験みたいなものです。で、まあこういうことを総称して検索機能の強化と言ってるだけでして、要するに自分用のメモです(^^;。

というわけで今回はIDでの検索です。これは↑の3つの中ではおそらく一番実装が楽だろう?という予想の元に、まず試してみることにしました。

IDでの検索というのはどういうことかというと、今まで実装してきた検索方法はNormalSearchにしてもKanaSearchにしても名称とかふりがな(読み)のような実体?というか現物?というかなんかそんな感じのフィールドを検索対象にしていましたが、IDでの検索というのは文字通りそれぞれのテーブルのプライマリーキーになっているようなフィールド(ほとんどidという名前がついている)で検索しようということです。

この検索方法がどのようなときに必要か?というと、例えば都道府県別で合併市町村の一覧を出力したいとします。UI的には都道府県をダダっと並べておいて、それをクリックしたらその都道府県での合併市町村の一覧を表示する〜といったようなものをイメージしています。

<都道府県別一覧>
北海道|青森|秋田|岩手|・・・|鹿児島|沖縄|

↑こんな感じで…

この時都道府県名にアンカーをつけて適宜メソッドを呼び出すわけですが、一見NormalSearch(検索フィールドからのサーチ)でも大丈夫そうな気がします。ですが例えば上記では北海道を除いて煩雑さを避けるために○○県の県の部分を省略しています。これをこのままアンカーに利用してNormalSearchに送信すれば事は足りる?とおもわれます。が!
anchor('gappei/search/'.urlencode('京都'), '京都')

のようなことを不用意にやってしまうと、この場合なら京都府の一覧とともに東“京都”の一覧も表示されてしまったりするわけです。

この例だと回避策としては「県」を省略せずに渡してやれば済む話ですが、前回の「伊達市」のように異都道府県同一市町村名?みたいなパターンだとNormalSearchでやろうとすれば北海道&伊達市、福島県&伊達市のようにANDで絞込検索?のような形にするしかありません。

まあそれでも言いといえばいいのですが、やはりダイレクトに検索?というか指定できたほうが便利なことは確かです。この市町村合併データベースのような例だとあまり必要性はないかもしれませんが、映画やCD・書籍などのデータベースだと同名異作品という場合は頻発するのではないかとおもわれます。

ということで前置きはこのぐらいにして、実装の過程をメモっておきます。

まず五十音パッドの検索部分の実装(多態編)で作成した検索オブジェクトクラスに新たにIdSearchというクラスを作ります。
class IdSearch extends SearchMode
{
function __construct(array $condition)
{
parent::__construct($condition);
}
}

他の検索オブジェクトクラスと同じようにSearchModeクラスを継承する形にします。次にwhere()の実装ですが、これは$condition(検索条件:コンストラクタに渡される)の形を、
参照するフィールド名 => ID番号

のように渡すことにするので、以下のようにほとんどNormalSearchのwhere()と同じようになります。
function where()
{
$CI =& get_instance();

$CI->db->where($this->getCriteria());
}

NormalSearchの場合はdb->like()でクエリを生成していましたが、今回は「=」でいいわけで、db->where()にそのまま検索条件配列を渡しています。次にgetSearchTitle()ですがこれはとりあえず
function getSearchTitle()
{
return 'ID Search';
}

とだけ書いて後回しにして、まずはControllerで新たにメソッドを定義します。
function idSearch($field, $id=0)
{
$this->cond = new IdSearch(array($field => $id));
$this->phpsession->save('searchObj', serialize($this->cond));
redirect('gappei/index/');
}

$fieldと$idをそれぞれURIの第3,4セグメントから受け取って、先ほど作ったクラスをインスタンス化してindex()にリダイレクトしているだけです。

これでとりあえずデータは受取れるはずですが、まだViewというかUIを作っていないのでさしあたってURL入力欄から
http://localhost/gappei/idSearch/pid/1

のように入力して試してみます。

それぞれのidのフィールド名は、

pid:都道府県ID
nid:新市町村ID
oid:旧市町村ID

のようになっているので、上記のURIだと北海道の一覧が出力されます。ここでいろいろパラメータを変えて試してみると、まあ当然ですがフィールド名に無いモノを指定したり、IDの部分(URIの第4セグメント)を文字列にしたりするとエラーが出るので、Controllerにヴァリデート(入力チェック)のコードを書きます。

Controller/idSearch()
function idSearch($field, $id=0)
{
/*$idは初期値を0にしてURIの第4セグメントが空の送信に対処する
$idは数値以外は削除
$fieldはpid, nid, oid以外は何もしないでindex()にリダイレクト
※$idの最大値のチェックは行わない、仮に送信された場合でもデータ件数0になるだけ*/
$id = preg_replace("/[^0-9]/", "", $id);

if (preg_match("/^(p|n|o)id$/", $field))
{
: (インスタンス作成等)

ほとんどヴァリデーションのコードを自力で書くことにした。でやったことと同じような感じです。これでとりあえず想定されるようなURIからのエラーは出なくなりました。

次に後回しにしていたgetSearchTitle()をちゃんと書きます。
function getSearchTitle()
{
$CI =& get_instance();

$criteria = $this->getCriteria();
$key = key($criteria);
$id = $criteria[$key];

$searchTitle = '';

switch ($key) {
case 'pid':
$CI->db->select('name')->from('prefs')->where(array('id' => $id));
break;
case 'nid':
$CI->db->select('city as name')->from('newcities')->where(array('id' => $id));
break;
case 'oid':
$CI->db->select('oc as name')->from('oldcities')->where(array('oid' => $id));
$searchTitle = '旧';
break;
}

$row = $CI->db->get()->row_array();
$searchTitle .= (count($row) > 0) ? $row['name'] : '';

return $searchTitle;
}

まあ見ての通りあんまり綺麗じゃない(ってか汚い!)コードです(--;。

単に名称を出力するだけなのに、何故かこんなに長くなっちゃいました。これはひとえにメソッドが引数を受け取っていないことからくるモノと言えそうです。

冒頭述べたように大抵の場合はUI?というか、要するにHTMLのアンカーの文字列イコール検索タイトルなわけです。なのでそれをメソッドの引数に渡すことができれば単純にそれをreturnするだけで済むはずなんですが、他のクラスとの兼ね合いから引数を取らない形でgetSearchTitle()が定義されているので、検索オブジェクトを元にいちいちDBに問い合わせしてタイトルを得ているわけです。

そもそも取ってくるテーブルのフィールド名がまちまちなため、まず検索オブジェクトのキーを元にswitch文で分岐して、それぞれのテーブルに合ったクエリを生成しています。旧市町村の場合は頭に「旧」と付けたかったのでこれはハードコーディングです(--;。

最後に$rowをcountしているのは結果セットが無い場合$row['name']を代入しようとするとエラーがでてしまうためです。これはidの値が最大値を超えた場合に起こり得ます。ここでこのように実装しておくことでController側のメソッドでは最大値のチェックをしていません。

まあ汚いですけど親クラスのgetSearchTitle()の定義に従って引数を受け渡さないように書くことで、Controller(のindex()メソッド)は一切変更せずに検索タイトルを受取ることが出来ます。
//検索タイトル取得
$data['searchTitle'] = htmlspecialchars($searchObj->getSearchTitle());

最後にViewを作ります。一応構想としては都道府県別・新市町村別・旧市町村別などの項目でワンクリックで目的の名称の一覧を表示できるようなモノを考えていましたが、この合併データベースでは新市町村別・旧市町村別などはずらずらと項目を並べてもほとんど意味がないため、都道府県別だけに留めました。

id_search.png

まあこんな感じのモノを五十音パッドの下に表示するということです。他の項目別メニュー?も階層的にしてクリックしたら展開する〜みたいなのまで作ろうとも考えましたが、あまり本筋とは関係ない部分なので止めました。

またこの出力も一応ファイル的にはlist.phpとして分離してgappei_detail.phpやgappei_simple.phpなどのメインViewからロードしていますが、list.php自体は単なるHTMLです。
<h3 class="ui_title">都道府県別</h3>
<p class="prefs">
<?=anchor('gappei/idSearch/pid/1', '北海道');?><br />
<?=anchor('gappei/idSearch/pid/2', '青森');?>|
<?=anchor('gappei/idSearch/pid/3', '岩手');?>|

:(中略)

<?=anchor('gappei/idSearch/pid/46', '鹿児島');?>|
<?=anchor('gappei/idSearch/pid/47', '沖縄');?>
</p>

HTMLヘルパを使ってul()メソッドで出力しようかな?とも考えたのですが、<ul>だと一行一都道府県になってしまい無駄に長くなるので却下しました。このようなある意味ランダム?な表示だとやはり手動でやるしかないですかね〜?まあとりあえず今回はUIよりもID検索が主なのでこれでヨシとしました。

何度も言うようですが、この合併データベースだとID検索はあまり意味がないように思えますが、データベースの内容によってはこれが結構重宝するんです。というか無いとヤバイ(謎)。

というわけで次回は検索フィールド内でのAND検索の実装について書いてみようとおもいます。
ラベル:codeigniter PHP
posted by ciallost at 05:15| Comment(0) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年12月02日

MySQLの照合順序:utf8_unicode_ciってなんぞ?

前回はヴァリデートのコードをそれぞれのメソッドの頭に記述して、なんちゃって入力チェックのようなことをやった経緯を書きました。あんな実装でも変なエラーが出なくなってきたので、まあヨシとしてます。

んで、ようやく今回は検索機能の強化?について書けるかな?と考えていたんですが、どうやろうか?などと思案しながらスクリプトをいじっていると奇妙な現象?に遭遇したので、検索機能の強化はまた後回しにして今回はそれについて書きます。

結論から言うと自分がMySQLの照合順序なるものを全く理解していなかったというだけの話です。「あ〜それね」「今更何言ってんの?」と言い切れる方は以下は読む必要はありません。なので以下は個人的なメモです(愚痴とも言う…マタカヨ)

まず奇妙な現象というのは以下のようなモノです。

例の五十音パッドで「た」で始まる新市町村を検索します。

伊達市 2006-03-01
大仙市 2005-03-22
田村市 2005-03-01
伊達市 2006-01-01
高崎市 2006-01-23
高崎市 2006-10-01
胎内市 2005-09-01
 :

簡易表示だと上記のような感じに結果が表示されます。

ここで伊達市や高崎市がダブって表示されているのは別段問題ではなく、合併日を見ればわかりますが要するに二段階で合併してる?というか、高崎市の場合だとまず2006-01-23に高崎市・倉渕村・箕郷町・群馬町・新町の5つの市町村が合併して高崎市になり、その後2006-10-01に榛名町が合併したということです。伊達市の場合は単純に同名の市が北海道と福島県にあったというだけです。

ん? 伊達市? だてし? 「だ」てし?

これが奇妙な現象で、「なんで伊達市が検索されてるんだ?」という話です。SQLは下記のようになっています。
SELECT pid, pref, nid, nc, yomi, oid, oc, oyomi, gdate FROM (SELECT DISTINCT pid, pref, nid, nc, yomi, gdate FROM cities WHERE SUBSTRING(yomi FROM 1 FOR 1) = 'た' ORDER BY yomi asc LIMIT 10, 10) AS vn JOIN newcities_oldcities j ON vn.nid = j.newcity_id JOIN oldcities o ON o.oid = j.oldcity_id WHERE SUBSTRING(yomi FROM 1 FOR 1) = 'た'

改行してないから見づらいですが、問題はWHERE句のところで…。

ちなみに外側のクエリとサブクエリに同じWHERE句が発行されていますが、これはバグだとか今回の件に関連することではなくて仕様です。詳細は五十音パッドの検索部分の実装(switch case編)の後半部分で述べているとおりですが、旧市町村が検索された場合だけでなく常に外側のクエリにもWHERE句を生成する実装にしてあります。どうしてかというと条件判断を書きたくなかったもんで(--;

で、それはともかく検索条件は
WHERE SUBSTRING(yomi FROM 1 FOR 1) = 'た'

なわけです。「だ」なんてどこにも書いてないじゃん!実はこの部分のクエリを生成しているコードは手抜き?というか、気づいてはいたんだけどとりあえず後回しにしていた部分でした。

具体的には「た」だけではなくて「た」or「だ」のように検索したかったのです。クエリを書くとすれば…
WHERE SUBSTRING(yomi FROM 1 FOR 1) = 'た' or SUBSTRING(yomi FROM 1 FOR 1) = 'だ'

みたいな感じでしょうか?当然半濁点の可能性のあるハ行などは「は」「ば」「ぱ」に関してorで繋げなければなりません。しかも濁点のつかない行とかもあるし(マ行、ラ行)。そんなわけで「こんな場合分けやってられるか!」と半ば放棄して後回しにしていたわけです。

ところがなんと!いざ検索してみれば実装もしてないのに期待通りの結果が表示されてるぅ?!これは寝ている間にPCの中の人が勝手に…ナワケナイ

そもそも自分の中では↑のように考えていたわけで、これは動作は期待通りですが、コード的にはバグのようなもんなわけです(個人的には…)。

なので五十音パッドを出力する際にもわざわざ半角カナに変換して、濁点・半濁点を取り去って、また平仮名に戻して、重複削除して〜のようなコードも書いていたわけです。
function _getCap($field)
{
//濁点・半濁点を清音に吸収するため、半角仮名で処理した後に重複削除
foreach($this->Gdb->getCap($field)->result_array() as $row)
{
$cap = mb_convert_kana($row['cap'], 'h', 'UTF-8');
$cap = mb_substr($cap, 0, 1, 'UTF-8');
$kana[] = mb_convert_kana($cap, 'H', 'UTF-8');
}

$kana = array_unique($kana);

return $kana;
}

このメソッドはデータベースから取ってくる時のコードなので、このようにmb系の関数を使ってなんとか濁点を吸収(濁点・半濁点などを清音の文字と同一視するの意)できていますが、上述のように(清音で)検索する際にはこれの逆変換?というか、とにかく面倒くさそうだなぁということで後回しにしていたのです。

んで、まあおおよそのところMySQLがなんかいたづら?してるんだろう?というところまでは想像したんですが、これがネットで検索してもなかなか情報がでてこない。(ってか検索の仕方が悪いという噂が…)

おそらくこういう事って、MySQL使いの方やPHPerの方々の中では常識に類するぐらい当たり前のことなんでしょうね。なのでいちいちBlogとかに書く人もいないし、質問すらする人もいない。当たり前すぎて知らない奴はアホ、なことなんだろうと思いました。

で、散々調べてようやく、どうも照合順序とかいうのが関係してるっぽいということが判明。それでもそのものズバリを説明してるところは発見できず、それでもなんとかutf8_unicode_ciのciの部分は大文字/小文字を同一視するってことだけはわかりました。

でも日本語の大文字/小文字ってなんだ???

こういう部分は本当に海外製っつーか1バイト圏のソフトなんだなぁ?と思い知らされる部分で、アルファベットのことしか考えてない、っつーかもちろんこういう機能が実装されているので何も考えてないわけではないとは思いますが、根底にある発想というかなんというか…。

日本語(の文字)の大文字/小文字といわれてピンときますか?自分は全くわからないです。「あ」と「ぁ」のことかと…。「ゃ」とか「ぃ」とか、「ι」とか…ってこれは日本語じゃね〜ヨ。

逆の立場で考えればアルファベットの楷書体/行書体とか、ローマ字の永字八法とか言われてピンとくるガイジンがいるのか?…と。

で、愚痴はこれぐらいにして、とにかくこのutf8_unicode_ciを指定していると濁点・半濁点はおろかカタカナもなんと半角カタカナも同一視してしまうということが実験で確かめられました(^^;実験…っていったい

なので例えば新市町村フィールドに「る」と入力して検索を実行すると…

うるま市 2005-04-01
つがる市 2005-02-11
つるぎ町 2005-03-01
南アルプス市 2003-04-01

う「る」ま市・つが「る」市・つ「る」ぎ町というトンデモなひらがな市町村に加えて、南ア「ル」プス市というさらに上をいくトンデモな市も検索されて和ませてくれます。

んで、もしこういうことならば↑の方で晒した五十音パッドの出力に関する部分のコードはほとんど必要ないんじゃないか?とおもい以下のようにしてみました。
function _getCap($field)
{
//濁点・半濁点を清音に吸収するため、半角仮名で処理した後に重複削除
foreach($this->Gdb->getCap($field)->result_array() as $row)
{
$kana[] = $row['cap']; //単純に代入してるだけ

/*以下は無駄な処理っぽかったのでコメントアウト
$cap = mb_convert_kana($row['cap'], 'h', 'UTF-8');
$cap = mb_substr($cap, 0, 1, 'UTF-8');
$kana[] = mb_convert_kana($cap, 'H', 'UTF-8');
*/
}

//$kana = array_unique($kana);

return $kana;
}

すると今度は五十音パッドがなんかおかしい!

error_50on_pad.png

「てねへめらりるれろ」は元々頭文字にない文字なのでディム化されていますが、修正前は存在していたはずの「た」「こ」が修正後ではディム化されています。これはどうしたことだろうか?とそもそものテーブル(というかビューですが)を調べてみると…

phpMyAdminで表示(SELECT * FROM ncap)
ncap.png

のように「こ」「た」の(あるべき)部分に「ご」「だ」と出力されています。そこで今度はこのビューを生成しているSQLを確認してみると…
CREATE VIEW ncap AS select distinct substr(newcities.yomi,1,1) AS cap from newcities order by newcities.yomi;

と、特に問題なさそうなのですが、実はここに照合順序が影響してきていて、まずORDER BY句であいうえお順に並び替えようとしているのですが、utf8_unicode_ciという照合順序だと「た」も「だ」も同じモノとして扱われてしまいます。つまり「た」と「だ」など同一視される文字間ではソートはされないで、DB格納順に出力されるということです。

さらにDISTINCTで重複を削除した際にDBに格納された順で重複削除が行われてしまい、たまたま「こ」と「た」で始まる市町村は「ご」や「だ」ではじまる市町村が先に格納されていたためこのような結果になったわけです。

そしてその結果を濁点・半濁点を含まない五十音(「あ〜わ」まで及び「を」「ん」を除くので正確には44文字)と比較しているので、最終的に「こ」と「た」はディム化されてしまったというわけです。

というわけで↑の五十音パッド出力のコードは、$kana配列をarray_uniqueで重複削除している部分だけが不必要で、半角カナに変換して濁点・半濁点を清音に吸収している部分は必要だったということになります。

では当初の予定?どおりに動作させるにはどうしたらよいか?というと、これはあっけないくらい簡単な話で、単に照合順序を変えてやればいいだけです。

ncap(およびocap)はビューなので、大元のnewcitiesとoldcitiesのyomi, oyomiフィールドの照合順序をutf8_binにすれば、修正前のコードの意図した通りの動作になります。(つまりビュー時点では濁点・半濁点・清音が違うものとして認識され、↑のコードではarray_uniqueでさらに重複削除しないと、例えば「た、た」とか「は、は、は」のように出力されてしまうということです)

但し照合順序をutf8_binにしてしまうと、五十音パッド出力のコードは↑の修正前でよいのですが、今度は検索する方のコードが上述したとおり非常に煩雑なものになりかねません。

というわけで結論としては

照合順序:utf8_unicode_ci
五十音パッド出力コード:array_unique部分を削除
検索コード:そのまま

ということにしました。

今回の例のようにふりがなの頭一文字のような場合はutf8_unicode_ciが便利かとおもいます。ただこの照合順序にした場合は濁点・半濁点・清音の区別をつけたい場合に不都合が生じます。その場合はutf8_binにするべきだとおもいます。

またutf8_binの場合でも今回の例のようなふりがなの頭一文字だけを取得したい(検索したい)場合は、もっと単純にふりがなの頭一文字だけのフィールドを作って、入力時点で濁点・半濁点を清音に統一してしまうという方法もあります(つまり伊達市でも「た」と予め入力しておく)。実際大元のスクリプトでは全部の読みではなく頭一文字だけのフィールドになっていました。

ということでもしかしたら意外と知られていないことだったりして?ナワケナイッスネ(--;
ラベル:MySQL
posted by ciallost at 06:38| Comment(2) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年12月01日

ヴァリデーションのコードを自力で書くことにした。

前回、CodeIgniterのValidationクラスは、残念ながらちょっと使えないということを書きました。そうは言っても入力チェックはしないとならないので今回はその過程のメモです。

ただ自力で書いたといっても自作Validationクラスを書いたとかそういう高級なことではなく、単に今までのスクリプト(主にController)にチェックのためのコードを付け足したってだけなので、今回のメモはCodeIgniterとはほとんど関係ありません。

それと平行して?というか前後して?スクリプト中で気になっていた部分をちょこちょこ書き直していたりしたので、それも一応メモっとこうとおもいます。なので今回は公開用というよりは自分用のメモと言うべき内容ですので、予めご了承ください。

まず現時点で「入力」がどれだけあるのかを確認しておきます。

検索フォーム:フィールド×3(都道府県・新市町村・旧市町村)
五十音パッド:平仮名44文字
五十音パッド:タブ×2(新市町村・旧市町村)
表示モード:チェックボックス(真偽値)
表示件数:プルダウンリスト(10, 20, 30, 50, 100)
ソート:(都道府県・新市町村・旧市町村・合併日)
ページングのオフセット値:数値

そしてそれぞれの入力に対してほぼ1:1で対応するアクション?というか要するにControllerクラスのメソッドが定義されています。

search()
kanaSearch($cap)
changeKanaTab($field)
changeDisp()---*1
sort($field='nid')
index($offset=0)

*1:表示モードと表示件数に関しては元々はchageDispMode()とchangeDispNum()という別々のメソッドでしたが、changeDisp()にまとめました。

index()以外のメソッドは必ず最後にindex()にリダイレクトするようになっています。なのでページングのオフセット値($offset)以外の入力値は、全てそれぞれのメソッド内の先頭で入力チェック(ヴァリデート?)することにしました。

まずsearch()ですがこれは検索オブジェクト的?にはNormalSearchというインスタンスを作成している場所?で、チェックといっても数字のみとか英数のみのように限定することができません。

最終的にはここでの入力値がSQLのWHERE句に渡されますが、その際はアクティブレコードでクエリを組み立てているので、SQLインジェクションの心配はない?ということにしておきます(^^;。

なので基本的にはこの段階ではノーチェックなのですが、一つだけ気になることがあったので、そのチェックだけ施しています。

気になっていた事というのは、3つのフィールドが全部空白だった場合の処理で、今まではWHERE句なしつまり全データ表示という仕様?になっていましたが、さすがに全データというのはナンなんで、3フィールド共空白の場合は検索しないという仕様に変更しました。
function search()
{
//全フィールドが空の場合は、全件取得ではなく直前のクエリに戻す(つまり何もしない)
if (implode('', $this->input->post('condition')) == '')
{
redirect('gappei/index/');
}

}

次に五十音パッドの平仮名ですが、これはCodeIgniter:日本語URIでThe URI you submitted has disallowed characters.で書いたとおり、CodeIgniterの$config['permitted_uri_chars']で大方のやばそうな入力は弾かれます。

まあこれに引っかかった場合いきなりexit()という仕様はアレなんですが(^^;…。それはまあ置いといて、↑の機能だけでは英数などは弾かれないので、平仮名の「あ〜わ」以外の文字は弾くような設定にしました。
function kanaSearch($cap)
{
/*
平仮名以外の日本語文字はconfigで弾く設定になっているが、
それら以外の英数記号などをここで弾く
[参照]config/config.php-127行目$config['permitted_uri_chars']
*/

if (!preg_match("/[あ-わ]/u", $cap))
{
redirect('gappei/index/');
}

}

CodeIgniter:日本語URIでThe URI you submitted has disallowed characters.で学んだとおりUTF-8なので/uオプションをつけてpreg_matchを使っています。

次に五十音パッドのタブ選択ですが、これは単純にncapかocapの2通りしかないので、正規表現でそれら以外は全部 ncap(デフォルト)として処理しました。
function changeKanaTab($field)
{
//タブはncapかocapにマッチするもの以外はncap
$selectedTab = preg_match("/^(n|o)cap$/", $field) ? $field : 'ncap';

}

表示系(モード・件数)に関しては、件数は数値以外は強制削除にしましたが、モード(チェックボックスの値)に関してはあえてヴァリデートはしていません。
function changeDisp()
{
//表示件数は数値以外削除
$dispNum = preg_replace("/[^0-9]/", "", $this->input->post('dispnum'));
set_cookie('dispnum', $dispNum, Gappei::COOKIE_LIFE);

//表示モードは内容の如何を問わず存在すればTRUE、なのでヴァリデートしない
set_cookie('dispmode', $this->input->post('dispmode'), Gappei::COOKIE_LIFE);

}

これはindex()内で読み出す際に内容は読み取らずに単に存在するかどうか?だけで真偽値を判断するようにしたからです。
//表示モード取得
$data['dmChecked'] = (get_cookie('dispmode') <> '') ? TRUE : FALSE;

次にソートに関してですが、これはヴァリデートの前に多少?(かなり?)手直ししました。まず今までは単純に、新市町村のソートならばncフィールド(つまり新市町村名)をキーにソートする仕様でしたが、漢字の順番に並べてもあまり意味がないので、新旧市町村に関してはその読みのフィールド(yomi, oyomi)をキーにすることにしました。

また都道府県も都道府県名をキーにするより都道府県IDで並べた方が北から南〜のように整然とするので、DBのビューやクエリを修正してpref_idを取ってくるようにし、ソートキーにはそれ(pid=エイリアス)を使うようにしました。

さらに今まではソート方向を引数として取らずにメソッド内でチェックしてソート方向を決定していましたが、この実装だと煩雑になるので引数でソートキーとソート方向を受取るようにしました。

修正前sort()
function sort($field='nid')
{
//ソート方向セット
$order='asc';
$now_order = unserialize($this->phpsession->get('sort_key'));
if ($field == $now_order['field'] && $now_order['order'] == 'asc')
{
$order = 'desc';
}
//ソート条件セット
$this->phpsession->save("sort_key", serialize(array('field'=>$field, 'order'=>$order)));
redirect('gappei/index/');
}

修正後sort()
function sort($field, $order='asc')
{
$sortkey = array('field'=>$field, 'order'=>$order);

$this->phpsession->save('sort_key', serialize($sortkey));

redirect('gappei/index/');
}

これに伴いViewでテーブルヘディングを出力している部分も以下のように変更しました。
$th = array(
'pid' => '都道府県',
'yomi' => '新市町村',
'oyomi' => '旧市町村',
'gdate' => '合併日',
);

function setTableHeading($th, $sort)
{
$th_tag = '';

foreach ($th as $field => $column)
{
$th_tag .= "<th>";

if ($field == $sort['field'])
{
if ($sort['order']=='asc')
{
$order_mark = '▽';
$order = 'desc';
} else {
$order_mark = '△';
$order = 'asc';
}

$th_tag .= anchor("gappei/sort/$field/$order", $column);
$th_tag .= " <span id=\"order_mark\">".$order_mark."</span>";

} else {
$th_tag .= anchor("gappei/sort/$field", $column);
}

$th_tag .= "</th>\n";
}

return $th_tag;
}

今まではパラメータとしてフィールド(=ソートキー)しか渡していなかった部分を、ソートが行われているカラムの場合はURIの第4セグメントでソート方向を渡すようにしています。ソートが行われていないカラムは以前同様ソートキーしか渡していませんが、これはControllerのsort()でfunction sort($field, $order='asc')のようにソート方向がない場合はascを初期値としていることで解決?しています。

※修正前のテーブルヘディング出力コード
CodeIgniter:ソート機能を付けてみる

そしてヴァリデーションですが、これは単純に受取る予定のない文字列はデフォルト値(nid asc)にしてしまう設定です。
function sort($field, $order='asc')
{
$fld = preg_match("/^(pid|yomi|oyomi|gdate)$/", $field) ? $field : 'nid';
$odr = preg_match("/^(asc|desc|)$/", $order) ? $order : 'asc';

}

最後にページングのオフセット値ですが、これはまず数値に限定することと、もう一つは値の取る範囲を限定しています。
//offset値チェック:数値以外の送信、ブラウザで戻るボタンを押された場合の対処
$offset = preg_match("/[^0-9]/", $offset) ? 0 : $offset;
$offset = $data['count'] < $offset ? 0 : $offset;

$data['count']は文字通りデータの件数ですが、このチェックをしないとブラウザの戻るボタンをクリックした際に以下のような状況でエラーがでます。

・「か」で始まる旧市町村を検索する(142件)
 >表示件数は10件/頁
・最終ページを表示する(15ページ目)
 >この時のLIMIT句は LIMIT 140, 10
・「め」で始まる旧市町村を検索する(4件)
・ブラウザの戻るボタンをクリックする(エラー)
 >Invalid argument supplied for foreach()
 >この時のLIMIT句は LIMIT 140, 10 にも関わらず検索オブジェクトは「め」で始まる旧市町村になっているためエラー

つまり上記の例の場合だと「め」で始まる旧市町村の件数が4件しかないのにも関わらず、オフセットを140でデータを取ってこようとしている部分がまずいということです。

この場合SQLは文法的に正しい?(たぶん正しいとおもう)ので、DBではエラーは出ませんが、取得データはおそらくNULLだと考えられます。ただデータ件数はこのSQLの前に件数取得SQL(LIMITなしJOINなし)で取ってきているため、View側の<?if($count > 0):?>は真になってしまい、foreachブロックに入ってエラーが出ているというわけです。

ということでオフセット値をデータ件数と比較して、大きければ強制的に0、つまり1ページ目を表示ということにしました。文章で書くとわかりづらいですが、とにかくこれでエラーはでなくなりました。

というわけでValidationの実装はひとまず終了!これら以外にもちょこちょこスクリプトを修正しているので、ある時点でBlogで晒したコードとはかなり変化してきている部分もあるとおもいますが、そういう部分もおいおい解説できたらと考えています。

これで多少はコードが綺麗になってきたので、次回から懸案?となっている検索機能の強化を書いていきたいとおもいます。
ラベル:PHP
posted by ciallost at 04:52| Comment(3) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年11月30日

CodeIgniterのValidationクラスは使えなかった(--;

前回は愚痴っぽくなりましたが、今回も半分は愚痴です。

前回の最後で検索機能を強化してみたい…などと書きましたが、やはりその前にできるだけスクリプトを綺麗にしておきたいという気持ちが強く、まあごにょごにょいじってたわけですが、そういえば入力データを全然チェックしてなかったなぁ?などと今更になって気づきました。

そこで今回はCodeIgniterのValidationクラスを使ってみることにしたのですが、結論から言えば、これは使えませんでした。

ユーザからの入力データをそのまま信用してスクリプトで用いることは、ご法度というよりもはや犯罪行為に近いかのように、巷ではうるさく言われています。まあ個人的な趣味のスクリプトとはいえ、それを踏み台にされてどーのこーの…ということが日常茶飯事らしいので、このような傾向にあるのは致し方ないとはおもいますが、スクリプトを書く身としてはメンドイことが増えるなぁ…と。

で、そうした悪事を防ぐ意味でサニタイズとかヴァリデーションとか言われていますが、サニタイズってのはぶっちゃけデータベースにクエリを発行する時とHTMLに表示する時だけ気をつけてればまあいいかなぁ?と。

前者はSQLインジェクション防止のため、後者はXSS防止のためということらしいですが、これらはSQL組立でプリペアードステートメントを使うなりアクティブレコードに任せるなりエスケープするなり、後者は表示直前にえいちてぃーえむえるすぺしゃるきゃらすれば一応大丈夫ってことになってるらしいです。共通していることはクエリ送信の直前とかHTML表示の直前とかに処理を施すってことでしょうか。

んでヴァリデーションの方はサニタイズとは逆に入力された時にそれが想定の範囲内かどうか?をチェックする〜みたいな。

ところが想定される入力値というのはまさに千差万別で、同じformから送られてくるものでも、フィールドやその他のUIなんかで微妙にチェック基準が違ってしまいます。なので手続き型だと$_POST['hoge']を直接扱うことはまずしなくて、例えば…
$hoge = preg_replace("/[^0-9]/", "", $_POST['hoge']);

のように数値以外は問答無用で削除したり、文字列の長さを調べてみたりなどしてから$hogeの方を使ったりするわけです。

で、CodeIgniterのユーザガイドを見ると…
上で挙げたプロセスには何ら複雑なものはありませんが、たいてい大量のコードが必要になり、エラーメッセージを表示させるために、フォームHTMLの中に、さまざまな制御構造が書かれることになります。フォームの検証は、難しくなくつくれますが、実装するのはいつも面倒で退屈です。

なんて書いてあって、そうそうその通りその通り!これをすっきり書きたいんだ!と諸手を上げて賛同します。さらに…
CodeIgniter では、書く必要があるコードを最低限にとどめる包括的なバリデーション(検証)フレームワークが提供されています。 また、このフレームワークを使うと、HTMLフォームから制御構造を取り除くことができ、コードをわかりやすく、メンテナンスしやすいようにすることができます。

なんて太字でしかも緑色で書いてあって「さすがCodeIgniterだ!」「やはりこのフレームワークを選択して間違いなかった」と思わせてくれます。

んで喜んでられるのはその辺までで、結局使えません(--;。いや、まあみんながみんな使えないというわけではないでしょう(ないと思いたい…)。おそらくこれはログインの画面みたいなもの(それだけともいえよう)を作る場合には重宝しそうな気はしますが…。

まずココで作ってる検索アプリですが、検索フィールドが3つ(都道府県・新市町村・旧市町村)あって、そのテキストフィールドはnameが配列になっているわけです。
都道府県<input type="text" name="condition[pref]" />
新市町村<input type="text" name="condition[nc]" />
旧市町村<input type="text" name="condition[oc]" />

で、validationクラスの説明の通り
$this->load->library('validation');

$rules['condition[pref]'] = "required";
$rules['condition[nc]'] = "required";
$rules['condition[oc]'] = "required";

$this->validation->set_rules($rules);

のように書いて試してみましたが、↑のコードは動きません。

配列のキーの書き方が問題ありっぽかったので、$rules["condition['pref']"]とか$rules[condition[pref]]とかいろいろやってみましたが全部ダメでした。

そこでそもそもこのような書き方、つまり配列のキーに配列?を書くようなことがPHP的にNGなんじゃないか?とおもい、PHPのマニュアルを見ながらechoしたりしてみましたが、どうもこの書き方自体は間違ってない?というか一応ちゃんと値もセットできるし取り出すこともできました。

そこで今度は$_POSTの配列自体がどうなってるのかとおもいprint_r()してみたら…
Array
(
[condition] => Array
(
[pref] => 神奈川
[nc] => 相模
[oc] => 津久井
)
)

当たり前ですが、多次元配列になっているわけです。なので…
$rules['condition']['pref'] = "required";

のように書いてみました。するとさっきまでと違うエラーが…
A PHP Error was encountered
Severity: Notice
Message: Array to string conversion
Filename: libraries/Validation.php
Line Number: 195

つまり配列が強制的に?文字列にされちゃったよぉ〜ということらしいので、ソースを覗いて見ました。そしたらなんと$rulesは一次元配列だけを想定されているようです。どうもValidationクラスの説明にあるルールのパイプ機能が問題っぽく、「|」でパイプされてるルールをexplodeしている部分で上記のエラーが出ているようでした。

というわけでformのテキストフィールドの名前を配列にしている以上、このままだとvalidationクラスは使えません。libraries/Validation.phpのソースを若干いじってみようかなぁと途中までやってみたりしたんですが、どうもその部分のコードが長くて挫折しました。

んで、結局検索フィールドのヴァリデーションはあきらめて自力で実装することにしたのですが、↑のソースを見ていてちょっとオヤ?とおもったのは、なんと$_POSTがハードコーディングされていたという部分です。

結局コレってPOSTにしか使えないんじゃないの?という疑問が…。そういえばユーザガイドのどこにも任意のデータを渡してごにょごにょできますみたいなことは書かれてません。ということはココで作ってる検索アプリの場合だと五十音パッドから送られてくるデータとかソートした時とかページ移動した時とかのパラメータなんかはPOSTではないので、結局validationクラスは使えないってことになります(--;。

んで上述の通り唯一(ではないけど)使えそうな検索フィールドもnameが配列になってるとダメ、残るは表示件数と表示モード(これらは独立したformでしたがうざいので結局一つにまとめちゃいました)のformから送られてくるデータですが、なんかこれだけのために結構重そう(ユーザガイドにも書いてありますが)なValidationクラスをロードするのもなんだなぁ…と。

そうすると一体このクラスはどういう用途を想定してるんだ?ということなんですが、これは冒頭述べたとおりログインフォームぐらいにしか使えないんじゃないかな?。それ以外でこのクラス使ってる人がいたら教えてください。(あ、いや教えてもらわなくてもいいや…)
ラベル:codeigniter 愚痴
posted by ciallost at 02:46| Comment(4) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年11月28日

CodeIgniter:MVCの分離が怪しくなってきた

前回、慣れないオブジェクト指向でポリモーフィズム(もどき?)をやって力尽きました。一応当初の目標だったswitch文の駆逐には成功したものの、どうもコードが全般的に怪しくなってきた?というか書いてるうちにこれはControllerなのかViewなのかという区別が曖昧になってきました。

そもそもMVCというパターン?の厳密な定義?がよくわからない。自分的には勝手に

Model:データベースをごにょごにょするコード
View:HTMLを書く、ロジックは書かないで変数をechoするだけ
Controller:Viewにセットする変数を処理するところ

のような結構漠然とした感じで分けていましたが、コードを書いていくうちに↑の曖昧な定義でさえどんどん破られていくという…。

まず放っとくとViewがロジックだらけになっていくわけです(^^;。例えばテーブル出力するのにHTMLテーブルクラスを使ったとして、パラメータをセットしなきゃならない。これはそもそもVなのかCなのか?おそらく↑のような定義だとCだと思うんですが、index()内に書いたら最後、手続き型と変わらないぐらいindex()が長くなってしまう。

メソッドにしてindex()から出しちゃってもなんかすっきりしない。そうすると結局「テーブルを出力してるんだからこれはViewの仕事だ!」とかいう適当な&その場限りの&自分に都合のいい…なんか悪魔の囁きみたいなのが聞こえてきて、おもむろにViewファイルの下の方に<?phpとか書き始めちゃってるわけです。

メインのViewファイル(のHTMLの外)
<?php
function setTableHeading($th, $sort)
{
$th_tag = '';

foreach ($th as $field => $column)
{
$th_tag .= "<th>";
$th_tag .= anchor('gappei/sort/'.$field, $column);
if ($field == $sort['field'])
{
$order_mark = $sort['order']=='asc' ? '▽' : '△';
$th_tag .= " <span id=\"order_mark\">".$order_mark."</span>";
}
$th_tag .= "</th>\n";
}

return $th_tag;
}
?>

これぐらいだとまだfunctionにもなってるし、なんだったらController内にこのまま移すこともそれほど難しくない。だけど例えば五十音パッドを出力してるようなコードは、もうほとんどHTMLというより単なる手続き型のPHPスクリプトなわけで…。

ちなみに長いのでいちいちコード掲載しませんがPHPのコード部分だけで64行ありました。ほとんどが五十音パッドテーブル出力のためのパラメータのセットなんですが、こういうのはControllerに持っていった方がいいのかどうか???

そんな感じなんで、Viewファイルもパーツごと?に分けてみたりしましたが、結局どのファイルにもロジック?っぽい部分が少なからずある状態です。

Viewはそんな状態ですが、そもそものControllerの方も放っとくとすぐすごいことになってくる…っつーか、たぶん頭がオブジェクト指向的になってない?のか、CodeIgniter的作法がよくわからないというか…。

例えば前回晒した…
//検索オブジェクト取得
if ($this->phpsession->get('searchObj') <> '')
{
$searchObj = unserialize($this->phpsession->get('searchObj'));
} else {
$data['criteria'] = array('pref' => '', 'nc' => '', 'oc' => '');
$this->load->view('no_session', $data);
return;
}

//検索語句取得〜Viewに設定
if (get_class($searchObj) == 'NormalSearch')
{
foreach ($searchObj->getCriteria() as $key => $val)
{
$data['criteria'][$key] = $val;
}
} else {
$data['criteria'] = array('pref' => '', 'nc' => '', 'oc' => '');
}

この検索語句をViewにセットしている部分なんかは、勢いで適当に書いちゃってたわけで、ぐっちゃぐっちゃです。

んでこれを綺麗にしたいと思って…
//検索オブジェクト取得
if ($this->phpsession->get('searchObj') <> '')
{
$searchObj = unserialize($this->phpsession->get('searchObj'));
} else {
$this->load->view('no_session', $data);
return FALSE;
}

//検索語句取得
foreach ($searchObj->getCriteria() as $key => $val)
{
$data['criteria'][$key] = $val;
}

こんな感じに書き直したりしてみるわけです。まずNormalSearchの検索語句がない場合に配列を初期化している部分がうざいのでカットして、検索オブジェクトがNormalSearchかどうか判断してる部分もダサイのでカットして…みたいな感じで一見すっきりしてきてるようにも見えますが、これのおかげ?でViewの方でいちいち…
isset($criteria['cap'])?$criteria['cap']:'';

みたいなことを書かなきゃならないわけです。

まあissetの引数が関数の戻り値を直接書けないので、こうすればViewで受取った変数を書けるだけこっちの方がマシな気もするので、このようにしていますが、↑のMVCの定義?に照らし合わせるとどうなの?といった疑問はなかなか払拭できません。

Modelに関してはVやCと比べるとそれほど悲惨なことにはなってないような気もしないでもありませんが、SQL直書きに近い状態とか、発行したSQL“文”を再取得して使いまわしてたりとか、なんかカオスです(--;。

動きゃぁぃぃ!みたいなことを口走りましたが、今は動くけど次は動かね〜だろ!な状態です。んで、そんな中いい加減画面が見辛くなってきたので、cssでも書いてちょっと整形しようなどという考えが浮かんできてしまうわけです。

Viewがちゃんと切り分け出来てれば、本当にcssだけで整形できちゃったりするんでしょうが、ここはこれこの通りなわけで、cssだけの話じゃ済まない。

そもそもcssのファイルはどこに置けばいいんだ?という超初歩的事項から始まり〜長い道のり。

検索結果のステータス表示しようにも欲しい変数がセットされてない。取ってこようとすると今度は検索オブジェクト毎に場合分けしなきゃなんない。それをそのままベタに実装するとまたもやswitch文の嵐になるので、自作のでっちあげクラスを変更しないとなんない。

ちょっとしたアイコンやロゴ?みたいな画像を表示しようとしても相対パスで普通に書いても読み込めない(TT。base_url()とか使って絶対パスに変換してもsystem/application/images/hoge.pngとか書かなきゃなんない(のか?)。

とにかくそういう作業をしている最中も常に↑のMVCの分離?ということが頭のどこかをグルグルしてました。まあグルグルしてるだけで実際は分離とかとは程遠い状態になっているわけですが…。

そんなわけで今回はCodeIgniterとは直接関係のない愚痴もどきでしたが、なんとか紆余曲折の末cssで整形したので結果画面だけ晒しておきます。

css.jpg

本当はcssファイルとかViewやControllerで手を入れた部分をメモっとこうかとも思ったのですが、CodeIgniterとはあまりにも関係ないため止めました。いずれ完成したらソースとかもメモとして晒す予定です。

次回は検索機能を強化してみたいとおもいます。
ラベル:codeigniter 愚痴
posted by ciallost at 03:22| Comment(3) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年11月25日

五十音パッドの検索部分の実装(多態編)

前回はswitch文で分岐させることによって異なる検索モード(WHERE句が微妙に違う)を実装してみましたが、案の定?というかやはりコードはみるみるうちに汚くなってしまいました。

初めてswitch文なるものを見たときはif文の羅列よりは随分整然とコードが書けるものだなぁ?などと思った記憶がありますが、改めて自分の書いたコードを眺めてみると、switch文が如何にコードを見難くしているかがわかります。

しかも下手に手続き型ではなくなっているため、却ってコードの見通しが悪くなっているというのか…。せっかくフレームワーク使ってMVCの分離だ!とかやっていても、これでは手続き型でダラダラ書いていた方がまだマシだったのではないか?と思わせられるぐらいです。

そこでフレームワークの設計もオブジェクト指向に基づいているのだろうし、ここはオブジェクト指向的にswitch文をポリモーフィズムで書換えてみようとおもいました。

と、思ったのはいいのですが、実はオブジェクト指向的なプログラムなんてほとんど経験がなく、PHPでクラスなんて作ったことないし、継承といっても各種フレームワークのマニュアルにそう書いてあるから仕方がないのでextendsとか書いてみただけだし…、ぐらいの経験しかありません。なので以下に書いてあることは間違っても鵜呑みにしないでください。

ということで自分のプログラムスタイル的にはここらへんまで考えたらとりあえず書いてみる!ってことでController(のファイル)の上の方(class Gappei extends Controllerの外側という意味)にクラスを書いてみました。
abstract class SearchMode
{
private $criteria;

function __construct(array $condition)
{
$this->criteria = $condition;
}

function getCriteria()
{
return $this->criteria;
}

function where()
{

}
}

class NormalSearch extends SearchMode
{
function __construct(array $condition)
{
parent::__construct($condition);
}

function where()
{

}
}

class CapSearch extends SearchMode
{
function __construct(array $condition)
{
parent::__construct($condition);
}

function where()
{

}
}

何故Controller(のファイル)に書いたかというと、とりあえずどこでインスタンス化するかなぁ?と考えたときに、たぶんControllerのどっか?だろうとおもい安易にここに書き連ねただけです。

構造?(クラス階層?)的にはSearchModeという抽象クラスを継承したNormalSearchとCapSearchというクラスがあるという形です。where()というメソッド内でそれぞれのWHERE句を生成する予定で一応ガワだけ書いておきました。

$criteriaは検索語句を格納する予定のメンバでNormalSearchの場合はpostで受け取った配列、capSearchの場合はURLの第3セグメントで受取った文字になります。ということでそれぞれをインスタンス化するコードを書きます。

function search()
$this->cond = new NormalSearch($this->input->post('condition'));

function kanaSearch($cap)
$this->cond = new CapSearch(array($cap));

$condはGappeiクラス内のメンバで型があるとすればsearchModeということになるっぽいですがPHPなのでよくわかりません。とりあえずこの変数にどちらかのインスタンスが格納されるってことで…。
private $cond;

で、この後はindex()で$condをごにょごにょするなりModelに送る?なり…と思ったのですが、↑の二つのメソッド内で
redirect('gappei/index/');

のようにindex()にリダイレクトしています。すると$condが初期化されちゃうんですね(--;。まあ当たり前といえば当たり前なのかな???というわけで、ってゆーかいずれにしろページングとかソートとかやったら↑の二つのメソッドを経由しないで(つまりインスタンスが作られないまま)index()を呼ぶことになってしまうわけで、これはどっちにしろオブジェクトをどこかに保存しておく必要があるというわけです。なので…

//検索オブジェクトセット
$this->phpsession->save('searchObj', serialize($this->cond));

のようにして今までと同じようにシリアライズしてオブジェクトを丸ごとセッションデータに格納することにしました。

次にModelのクエリ組立部分で前回はswitch文を使用していたところをばっさりと削除して、次のように変更します。
$searchObj = unserialize($this->phpsession->get('searchObj'));
$searchObj->where();

シリアル化を元に戻してオブジェクトを取得し、それのwhereメソッドを呼ぶだけです。あとはwhere()の実装です。

まずNormalSearchの方ですが元々は
$this->db->like(array_diff($criteria, array('', NULL)));

と書いていました。$criteriaはメソッドの引数として受け渡されたものだったので、これは今度はクラスのメンバ、つまりgetCriteria()を呼べばよいということになります。

が、問題なのは$thisでこれはModel内で書いてあったから許される?っつーか、たぶん?いやほぼ確実にこのままじゃダメっぽいということはわかります。

んでユーザガイドを見てみたらライブラリの作成のところに
$CI =& get_instance();

のようにすれば、$thisのかわりにCodeIgniterのネイティブなリソースにアクセスできる旨が書いてあったのでそうします。

ということで結局できあがったNormalSearchクラス
class NormalSearch extends SearchMode
{
function __construct(array $condition)
{
parent::__construct($condition);
}

function where()
{
$CI =& get_instance();

$CI->db->like(array_diff($this->getCriteria(), array('', NULL)));
}
}

データベースのライブラリはオートロードする設定になっているのでここには書かなくても大丈夫みたいでした。でcapSearchクラスも同じような感じで…
class CapSearch extends SearchMode
{
function __construct(array $condition)
{
parent::__construct($condition);
}

function where()
{
$CI =& get_instance();

$field = get_cookie('kanatab') == 'ocap' ? 'oyomi' : 'yomi';

$CI->db->where(array("SUBSTRING($field FROM 1 FOR 1) = " => $this->get1stCriteria()));
}
}

ここでget1stCriteria()というのは親クラスに新たに作成したメソッドで、$criteriaは配列ということにしてあるので、それの最初の要素を返すだけのメソッドです。
function get1stCriteria()
{
$temp = $this->criteria;
return $temp[0];
}

db->where()に渡している配列は、キーに演算子を含ませています。試したところどうやらキーにスペースが含まれている場合は演算子'='は出力されなかったのでこのようになっています。スペースが含まれていない、例えばarray('pref'=>'あ')のような配列だと、自動的に'='を含む pref = 'あ'のようなWHERE句を生成してくれるみたいです。

$fieldというのは五十音パッドのタブがどちら([新/旧]市町村)を選択されているかで、取ってくるフィールドの名前(yomi/oyomi)が異なるためこのようになっています。これはデータベース設計を見直せばいらない部分かもしれません。が、とりあえずこのままでいきます。

最後にindex()の件数取得部分でModelのgetCount()に渡していた引数$criteriaを削除し
$data['count'] = $this->Gdb->getCount($data['sort_key']);

Modelの方も関数の頭の部分を変更します。
function getCount($sortkey)

これでデータ取得は問題ないのですが、ビューに検索語句を渡さなければならないため、結局セッションデータから検索オブジェクトを取得してそれをビューに渡す変数$dataに格納するという処理を書かなければなりませんでした。
//検索オブジェクト取得
if ($this->phpsession->get('searchObj') <> '')
{
$searchObj = unserialize($this->phpsession->get('searchObj'));
} else {
$data['criteria'] = array('pref' => '', 'nc' => '', 'oc' => '');
$this->load->view('no_session', $data);
return;
}

//検索語句取得〜Viewに設定
if (get_class($searchObj) == 'NormalSearch')
{
foreach ($searchObj->getCriteria() as $key => $val)
{
$data['criteria'][$key] = $val;
}
} else {
$data['criteria'] = array('pref' => '', 'nc' => '', 'oc' => '');
}

最初のif文でセッションデータの有無を調べてあれば$searchObjに格納しています。初回アクセス時などセッションデータがない場合の処理で、その際はno_sessionという検索フォームと五十音パッドだけのビューに転送しています。

次のif文で検索語句を取得し$data配列に格納しています。ここでの条件判断で使用しているget_class()はインスタンスのクラス名を返すPHPの組込関数です。

いずれも検索語句がない場合に配列を初期化していますが、この処理の書き方はなんか汚いというか長くなる原因というか…後々の課題ってことで…。

それにしてもこれらの処理(ビューに渡す)がなければかなりすっきりしたのですが…。それはともかく最終的には独自クラスを別ファイルにしてController(のファイル)からrequireする形にしました。
require_once('search.php');

で、一応これで動いてます。switch文も完全になくなりました。でも自分で書いておいてよくわからない(ってゆーか不安な)部分があるのでメモっておきます。

Model内でクエリ組立てる部分がその他のところは$this->db〜のように書いているのに、オブジェクトを使った部分では$CI->db〜になっているわけで、これはきちんと同じdbオブジェクト?を指しているのだろうか?ということ。

これはなんらかのデザインパターンに当てはまる形なのか?ということ。なんとなくステートパターンとかいうのに似てる気もしないでもないが???

あとどう考えても再利用とかできそうにないんですが、こんなもんなのか?ということ。

いずれにしても自分にとって初めて動いたオブジェクト指向的プログラムなので、くれぐれも真似しないように(TT。(動きゃぁいいんだぃ!)
ラベル:codeigniter PHP
posted by ciallost at 00:05| Comment(0) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年11月23日

五十音パッドの検索部分の実装(switch case編)

前回ようやく五十音パッドのUI部分ができたので、今回は検索部分の実装をしてみたいとおもいます。

実装といっても基本的には、すでに五十音(のうちの一文字)を取得するところまではできているので、あとは where yomi like 'あ%' みたいなWHERE句を生成してデータ取得すればいいだけの話です。

なんですが、これがやってみると意外とメンドイ。まず一番ネックな部分は、普通に?フォームのテキストフィールドから入力された地名を検索する場合と、五十音パッドから一文字選択されてその文字で始まる地名を検索する場合、といった感じで一口に「検索」といっても多少挙動が違う感じになっているというところです。

具体的にはフォームからの検索の場合は where nc like '%函館市%' のようなクエリを組み立てていますが、五十音パッドの場合は where yomi(の一文字目) = 'あ' みたいなWHERE句を生成したい(しなければならない)ことです。

しかもフォームからの検索の場合は(ブラウザからの)クエリが複数ある可能性があるので配列で受け取っていますが、五十音パッドからの場合は今のところ単一の文字(五十音一文字)なのでわざわざ配列にする必要はありません。

そんなわけでやり方はいろいろ考えられますが、とりあえず一番手っ取り早く、検索モードというフラグ?を導入して、switch文で分岐させることにしました。

単純なif文でなくswtch文にしたのは、この後さらに検索モードが増える可能性を考慮してのことです。というか実は大元のスクリプトでは「普通のフォームからの検索」「五十音パッドからの検索」に加えて「IDでの検索?(検索というかID指定でデータを取ってくる感じ)」の3通りの検索モードがあり、実際switch文で分岐していてすでにぐっちゃぐっちゃで手のつけられない状況になっています(^^;。

というわけでまずControllerのsearch()とkanaSearch()に検索モードフラグをセットするコードを加えます。

Controller->search()
$this->phpsession->save('search_mode', 0);

Controller->kanaSearch()
$this->phpsession->save('search_mode', 1);

要するに通常検索は0/五十音パッドは1ってことにする、ということです。

そしてsearch()の方と同様に検索条件をセッションに格納して、ソート条件をクリアします。

kanaSearch()
//検索条件セット
$this->phpsession->save("search_key", $cap);
//ソート条件初期化(ORDER nid asc)
$this->phpsession->save("sort_key", serialize(array('field'=>'nid', 'order'=>'asc')));

search()の方はpostで送られてきたデータを格納していましたが、kanaSearch()ではURIの第3セグメントでURLエンコードされた平仮名一文字を受け取っていますので、それ($cap)をそのままセッションデータに格納します。そしてindex()にリダイレクト…。

index()
//検索条件取得
switch ($this->phpsession->get('search_mode'))
{
case 0:
$data['criteria'] = array('pref'=>'', 'nc'=>'', 'oc'=>'');

if ($this->phpsession->get('search_key') <> '')
{
$condition = unserialize($this->phpsession->get('search_key'));

foreach ($condition as $key => $value)
{
$data['criteria'][$key] = $value;
}
}
break;
case 1:
$data['criteria'] = '';

if ($this->phpsession->get('search_key') <> '')
{
$data['criteria'] = $this->phpsession->get('search_key');
}
break;
}

index()の検索条件取得部分では、まず検索モードを調べてswitch文で分岐します。$data['criteria']は通常検索の場合は配列ですが、五十音検索の場合は配列にはなりません。

そしてModelもControllerと同じようにswitch文で分岐させて、それぞれの検索モードに合ったWHERE句を生成するようにします。
switch ($this->phpsession->get('search_mode'))
{
case 0:
$this->db->like(array_diff($criteria, array('', NULL)));
break;
case 1:
$field = "yomi";
if (get_cookie('kanatab') <> '')
{
if (get_cookie('kanatab') == 'ocap')
{
$field = "oyomi";
}
}
$where = "SUBSTRING($field FROM 1 FOR 1) = ".$this->db->escape($criteria);
$this->db->where($where);
break;
}

ここで$fieldというのは新旧どちらの地名の読みフィールドかを表す変数です。これは五十音パッドの上部のタブの選択状態で振り分けています。

WHERE句はほとんど手動生成(笑)なので、$this->db->escape()でエスケープ処理しています。SUBSTRINGは前回と同じMySQL側の文字列操作関数です。

動作結果(発行されたクエリだけキャプチャ)

query_50on.png

で、一応ほぼ期待通りに動作しました。↑の画はデバッグ出力の部分だけですが、ちゃんと期待通りのクエリが発行されてる様子はわかるとおもいます。

しかしながら、どーも気に食わないというか…。switch文を導入?すると途端にスクリプトが長くなりますね。ってゆーか汚い(--;まだ検索モードが2つだけなのでそれほどでもないですが、これが3つとかになるとせっかくCodeIgniter使って書き換えてる意味が半減しちゃうような気が…。

それとこれは今回の実装とは直接関係ありませんが、旧市町村の頭文字で検索された時にはその頭文字だけを持つ旧市町村名だけを出力するようにしたほうがいいような気がしてきました。

今の状態だと旧市町村の「お」をクリックすると…
北海道|せたな町|大成町|2005-09-01
|瀬棚町|
|北檜山町|
北海道| 釧路市 |釧路市|2005-10-11
|阿寒町|
|音別町|

こんな感じで「おおなりちょう」や「おとべつちょう」はちゃんと出力されますが、それと一緒に合併した旧市町村=瀬棚町、北檜山町なども同時に出力されてしまいます。これを…
北海道|せたな町|大成町|2005-09-01
北海道| 釧路市 |音別町|2005-10-11

このような出力にした方がよいのではないか?ということです。

で、これは比較的簡単に旧市町村のタブがクリックされている時は、クエリで外側のSELECTにもWHERE条件句をつけてやればいいだけです。まあそのための場合分けとかは多少必要ですが、一応そのような実装に変更しました。

ですがやはりswitch文だけはどうしても気に入らず、結局却下することにしました。なので今回の実装は、まあ無駄といえば無駄な作業だったってことで(--;。。。
ラベル:codeigniter
posted by ciallost at 22:21| Comment(0) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年11月21日

CodeIgniter:一度は使ってみたかったHTMLテーブルクラス

前回は日本語を含むURIでエラーが出る場合の(我流の)対処法を書きましたが、今回はそもそもの元になった五十音パッド(これもまた正確な?というか標準的な名前がわからないので勝手に命名していますが…)を作成していきたいとおもいます。

ただ作ってみると結構複雑な問題も絡んできてBlog的には長くなりそうな悪寒なので、今回はそのGUI?っつーか表面的な部分までの話を書きます。で、どんなものかといいますと…

gojuonpad.png

こんな感じで平仮名(の一部。「ん」とか「を」とかは省く)をただ並べた表です。各文字はアンカーが設定してあってクリックした文字で始まる○市町村が一覧表示される(予定)という仕様です。

表の上に書いてある|新市町村|旧市町村|はタブとおもってください(^^;。まだcssを書いてないのでなんだかわかりづらいですが、これで新/旧を選択するようにします。

で、今回はこの表をCodeIgniterのHTMLテーブルクラスで書いてみます。ちなみに以前にもHTMLテーブルクラスを試しに使ってみましたと書いたことがありました。その時はメインのテーブルをクラスを使って出力しようとして、結局却下されてベタ打ちになりましたが、今回はなんとかクラスで出力しています。ということでまずControllerでクラスをロードします。
$this->load->library('table');

で、新たにViewファイルgojuon.phpを作成して、メインのViewからロードしておきます。
<?$this->load->view('gojuon');?>

これでさしあたっての準備はできたので、gojuon.phpの中身を書いてみます。
$kana = array('あ','い','う','え','お',
'か','き','く','け','こ',
'さ','し','す','せ','そ',
'た','ち','つ','て','と',
'な','に','ぬ','ね','の',
'は','ひ','ふ','へ','ほ',
'ま','み','む','め','も',
'や','ゆ','よ', '', 'わ');
$gojuon = $this->table->make_columns($kana, 5);
echo $this->table->generate($gojuon);

無茶苦茶シンプルですね。単に「あ〜わ」までの一次元配列を作って、それをmake_columnsに渡してるだけです。まあこの状態だとアンカーもタブもないしテーブルテンプレートも適用してないので本当にただの五十音表なわけですが…(こんなん今時小学生でも見向きもすまい)

で、アンカーを付け足します。
foreach($kana as $chr)
{
if ($chr <> '')
{
$withAnchor[] = anchor('gappei/kanaSearch/'.urlencode($chr), $chr);
} else {
$withAnchor[] = '';
}
}

URLエンコードした文字をkanaSearch()に渡すリンクです。「やゆよ」と「わ」の間に空白セルを作りたかったので、空白の場合はリンクなしにします。もちろんmake_columnsには今度はアンカー付きの$withAnchor配列を渡すようにします。

で、一応kanaSearch()もControllerに記述しておきます。といってもechoだけですが…。
function kanaSearch($cap)
{
echo $cap;
}

あえてindex()にもリダイレクトさせないでおきます。実は前回はこのあたりまで書いてから、散々五十音パッドをクリックして、エラーが出たとか出ないとかあ〜だこ〜だやっていたわけです。成功すれば「ふ」とか「む」とか平仮名一文字が表示されます(なんだか虚しい)

次に↑の画像をよく見ると「て、ね、へ、め」の部分にアンカーが設定されていないのがわかるとおもいます(リンク色と地の文字色が似ててちょっと見難い)が、これは別にバグとかではなくて正常な結果です。

何をやっているかというと、状態としては新市町村のタブをクリックした時点で、次の動作としては五十音のどれか一文字をクリックすることを期待していますが、その際に不要な文字はアンカーを付けない状態にしたかったわけです。

不要な文字とは要するに新市町村のふりがなの最初の一文字に現れない文字です。いわゆるディム化というか、仮にクリックされたとしても検索結果は一件もない文字をあらかじめ選択できない状態で表示しておくということです。

で、これを実現するにあたって旧市町村テーブル(データベース)に読み(ふりがな)フィールドを追加しなければなりません。新市町村テーブルは元々yomiフィールドを作ってありましたが、旧市町村テーブルに新たにoyomiというフィールドを追加してふりがなを入力しました。
|  1  | 函館市 | はこだてし |
| 2 | 戸井町 | といちょう |
| 3 | 恵山町 | えさんちょう |
| 4 |南茅部町| みなみかやべちょう |
| 5 |椴法華村| とどほっけむら |


| 2021|具志頭村| ぐしかみそん |

さすがに2000件以上のふりがなを入力するとかなり辛い作業でした(嘘)ンナワケナイッショ

それはさておき、要するに欲しいのは[新/旧]市町村の読みの最初の一文字の一覧なわけです。なのでまずControllerにデータを取ってくるメソッドを追加します。
function _getCap($field) { }

んで一応MVC(かなり崩壊してきてるけど…)ということで、実際データベースから取ってくるコードはModelの方に書きます。
function getCap($field)
{
$this->db->select('cap');
$this->db->from($field);
return $this->db->get();
}

ここで疑問におもわれるとおもいますが、上記のActiveRecordの記述では…
SELECT cap FROM ($field:実際はテーブル名);

というSQL文しか発行されません。capってなんぞ?なわけですが、実はデータベース上で予めビューを作成することにしました(ってかしています)。
CREATE VIEW ncap AS
SELECT DISTINCT SUBSTRING(newcities.yomi FROM 1 FOR 1) AS cap
FROM newcities
ORDER BY newcities.yomi;

つまり新市町村テーブルの読み(ふりがな)フィールドの一文字目を重複なしで順番に並べたテーブルということです。ここでSUBSTRINGというのはPHPの関数でいえばsubstrとほとんど同じものです。ただしその後のFROM xx FOR yyみたいな部分の書式?はsubstrと若干異なっていて、FROM 1で一文字目という意味になります(substrは0から)。

上はnewcitiesの方から抽出?されるビューですが、旧市町村(olocities)の方もほぼ同じようにしてocapというビューを予め作っておきます。

で、先ほどのModelに作ったgetCap()メソッドで結果を取得しますが、Controller側ではさらにもう一工夫しなければなりません。
function _getCap($field)
{
foreach($this->Gdb->getCap($field)->result_array() as $row)
{
$cap = mb_convert_kana($row['cap'], 'h', 'UTF-8');
$cap = mb_substr($cap, 0, 1, 'UTF-8');
$kana[] = mb_convert_kana($cap, 'H', 'UTF-8');
}

$kana = array_unique($kana);

return $kana;
}

ただ結果を配列に入れて返すだけなら、$this->Gdb->getCap($field)->result_array()だけでいいのですが、よくよく考えてみると市町村の読みの最初の一文字には濁点や半濁点のつくものがある可能性があります。しかし今作成中の五十音パッドには濁点・半濁点はリストする予定はなく、例えば「た」で検索された場合に「だ」で始まるものも出力しようと考えています。

そこで少々トリッキーかもしれませんがデータベースからの取得結果(これは全部平仮名です)を半角カタカナに変換します。半角カタカナというのは濁点や半濁点が別の文字になっているわけです。

だ→ダ = タ + ゙
ぴ→ピ = ヒ + ゚

そして半角カタカナに変換後最初の一文字目を取得します。こうすることで濁点・半濁点を含む地名(の読み)も清音の仮名一文字にまとめることができます。

最後にそれを再び平仮名に変換してダブりを削除して返します。ダブりというのは、例えば地名に「だいまる」と「たかやま」があった場合に上記の変換をすると「た」が2つ出力されてしまうため、array_uniqueで重複を削除しています。

んでindex()では上記メソッドを呼んで$data['cap']などに格納してViewに送ります。
$data['seltab'] = (get_cookie('kanatab') <> '') ? get_cookie('kanatab') : 'ncap';
$data['cap'] = $this->_getCap($data['seltab']);

クッキーは五十音パッドのタブ(つまり新市町村か旧市町村か?)の選択状態を取得しています。

で、ここまでで[新/旧]市町村に存在する地名の読みの一文字目が濁点・半濁点を吸収した形で一覧として取得できたわけですが、今度はこれをViewに反映させなければなりません。
$dim_kana = array_diff($kana, $cap);
foreach($kana as $chr)
{
if (in_array($chr, $dim_kana) === FALSE)
{
if ($chr <> '')
{
$withAnchor[] = anchor('gappei/kanaSearch/'.urlencode($chr), $chr);
} else {
$withAnchor[] = '';
}
} else {
$withAnchor[] = $chr;
}
}

↑の方で五十音にアンカーを付け足している部分のコードを少々変更しています。まず$cap(データベースから取得した一覧)と$kana(五十音表)の差分を取ります。この配列($dim_kana)は[新/旧]市町村の地名の一文字目に使われていない文字の配列になります。

そしてforeachの中でin_arrayでその配列に該当するかどうかを調べて、該当する場合はアンカーを付けないようにします。これでようやく↑の画像で示したように該当地名のない五十音にはリンクがつかない形で五十音パッドを出力することができます。(ふぅ、結構メンドイ)

で!ここまで書いてハタと気がつきましたが、ってか今まで気がつかなかったのかよ!といったことなんですが、なんと「らりるれろ」の行がない!(--;(真面目にやってます。本当に気づいてなかった…)

ということで急遽(爆)$kanaに付け足します。
$kana = array('あ','い','う','え','お',
'か','き','く','け','こ',
'さ','し','す','せ','そ',
'た','ち','つ','て','と',
'な','に','ぬ','ね','の',
'は','ひ','ふ','へ','ほ',
'ま','み','む','め','も',
'や','ゆ','よ', '', 'わ'
'ら','り','る','れ','ろ');

こんな感じで…(って何がこんな感じだ)オオボケです。それでもちゃんとロジックは働いてるようで…

gojuonpad2.png

どうやら旧市町村で「ら」と「れ」で始まる地名はないようですね、ふむふむ(何がふむふむなのか!・・・と)

んで突然話はHTMLテーブルクラスに戻りますが、上記のコードだけでは↑の画像のようにはなりません。もちろんタブもまだ書いてません。ってことでまずはテーブルテンプレート。
$tmpl = array('table_open'=>'<table border="1" cellpadding="2" cellspacing="0" width="154">',
'cell_start'=>'<td align="center">',
'cell_alt_start'=>'<td align="center">');
$this->table->set_template($tmpl);
$this->table->set_empty(" ");

単にボーダーを指定したかっただけです。枠なしじゃなんか“パッド”っていう感じがしなかったもので。そんでcellも中揃えにしたかったので'cell_start'=>'<td align="center">って書いてみたら、なんと奇数行しか中揃えにならない。なので止む無くaltの方も指定しました。

set_emptyは空白セルをどうするか?って話らしいです。まあ1個しかありませんが…。で、これでgenerateすれば出力されるんですが、この辺の書き方っつーか書く場所?が結構自分の中でも曖昧になってきていて…。Viewファイルには書いているものの特にクラスを作ったわけでもないし、でも一応出力系のコードは外に出すか?とかなんとかかなりあやふやです(^^;。
<div>|
<?=anchor('gappei/changeKanaTab/ncap', '新市町村');?> |
<?=anchor('gappei/changeKanaTab/ocap', '旧市町村');?>
|</div>
<?=$this->table->generate($gojuon);?>

なんとなく外(<?php ?>の外という意味)に出してみました。まあ気休めにもなってませんが(ってかすぐに<?=とか書いてるし…)。一応echoはしなかったぞ!ということで…。

んでタブ部分はchangeKanaTabをパラメータ付けて呼びます。パラメータは↑で作ったデータベースのビューの名前そのものです。呼ばれた側の中身は…。
function changeKanaTab($field)
{
set_cookie('kanatab', $field, Gappei::COOKIE_LIFE);
redirect('gappei/index/');
}

のようにまたまたクッキーを設定しているだけです。

ちなみにクッキーに保存するかセッションデータに保存するかの分類?は、

検索条件・ソート条件などそのセッション中だけに限定したいモノ
→セッション
表示件数・表示モードなどある程度の期間保っていたいモノ
→クッキー

みたいな感じで適当に(テキトーに)分けています。なので五十音パッドのタブはクッキーです。(なんでだ???ま、いっか)次回はやっと検索部分の実装を書く予定です。
ラベル:codeigniter
posted by ciallost at 20:27| Comment(0) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年11月20日

CodeIgniter:日本語URIでThe URI you submitted has disallowed characters.

前回はソート機能を作ってみましたが、今回は検索支援UI?として五十音パッド(なんじゃそりゃ?)を作ってみようとおもいました。んでいろいろいじっていたら、日本語(もちろんURLエンコード済み)が含まれているURLで"The URI you submitted has disallowed characters."というエラーが発生しました。

ただこの件に関しては予め?というか前に散々ネットを検索してCodeIgniterの情報を集めていた際に、
Code Igniter - goungoun技術系雑記帳」様
とか
price-change:blog CodeIgniter | URIで日本語を使うのだ」様
などで語られていたため、その当時は「まあ自分で必要になった時に(上記サイトの説明にあるように)設定すればいいや」などと簡単に考えていました。

で、その設定にしてもできない(--;。最終的に自分で使う分には問題ない程度までは解決しましたが、果たしてその解決方法が正しいのかどうかはわかりません(毎度のことながら)。そんなわけでここに書いてある方法はあてにはなりませんが、一応メモっておきます。

まず何をしていたかというと、上に書いたように五十音パッド?なるものを作成していました。要するに「あいうえお〜」を並べておいて、例えば「あ」をクリックしたら、「あ」で始まる旧市町村を検索する〜みたいな感じです。(参項↓こんなやつ)
Wikipedia - Category:鎌倉時代の武士

でformは使わずanchorにしたかったので当然URIは
http://localhost/class/method/parameter

みたいになるわけですが、どういうロジックにせよparameterの部分にはどうしたって、[あ-わ]の平仮名五十音が入りそうな予感だったので、まずは適当なメソッドをでっちあげて
http://localhost/gappei/tekitou/あ(※実際は"%E3%81%82")

のように送信?してみました。

すると案の定
The URI you submitted has disallowed characters.

となり正常に動作しません。

ただここまでは上述の通り想定の範囲内だったので、まずはgoungoun技術系雑記帳様で解説されていたようにconfigの$config['uri_protocol']をAUTOからREQUEST_URIに変更してみました。

すると送信とか以前にメインページ自体が表示できなくなってしまいました。おそらくは自分のところの環境がcodeigniterをサブディレクトリ(アパッチのDocumentRootより深い場所)に置いていたりとかなんとか…そんなのが影響してるっぽかったので、この方法は却下しました。
(※誤解を招く恐れがあるので明言しておきますが、決して上記サイト様の情報が誤っているとか、上記サイト様に文句を言っているとかいうわけではありません。あくまでもウチの環境ではできなかったという話です。)

で、仕方がないので次にprice-change:blog CodeIgniter | URIで日本語を使うのだで解説されていた方法を試してみました。

$config['permitted_uri_chars'] の正規表現?みたいな部分に日本語を付け足す?という感じの方法なのですが、自分の場合は漢字は必要なく今回は平仮名だけが通ればいいので、「ぁ-ん」だけを付け足してみました。

すると
preg_match() [function.preg-match]: Compilation failed: range out of order in character class at offset 5

というエラーがでました。

そこでよくよく考えて…というかフト思ったのが、実はうちの環境は文字コードは全てUTF-8に統一する方向でApacheもPHPもMySQLも設定していたのですが、codeigniterのファイルの文字コードってなんだったっけかな?と。それでconfig.phpの文字コードを見てみたらSJISになっていました!まあ元ファイルには日本語は一切含まれていないので何でもよかった?んでしょうが、どうやら「ぁ-ん」←コレを記述したことでアレな感じだったので、UTF-8で保存し直しました。

すると今度はすんなり通りました!よしよしと思いつつ「あ」から始まってランダムに「き」とか「せ」「ぬ」「ほ」…とクリックして、ほぼOKだろなどと考えつつ「む」をクリックした瞬間…
The URI you submitted has disallowed characters.

(TT(TT(TT(TT(TT

わけがわからず、とりあえず気を取り直してもう一度、今度はちゃんと「あ」から一文字づつ順番にクリック。でも「み」までは大丈夫なのに「む」は相変わらずダメ。その後に続く「め」から「わ」はOK。

さっぱりわからないので、まずは手がかりとして文字コードと思い、「む」のURLエンコードを見てみると「%E3%82%80」。むむむむ、なんか臭い。特に末尾の%80ってのが気になる、非常に気になる。なんで気になったかというと、その昔SJISのダメ文字が確か80がどーのこーの…。

後で調べてみるとダメ文字は0x5Cが含まれている文字で、80は関係ありませんでしたが、それでもこの%80が怪しいと睨んだのは、あながち的外れなことでもなく、「あ」から「わ」の文字の中に%80という部分を含む文字は「む」だけだったわけです。

で、試しに
「%E3%83%80」=「ダ」カタカナのダ
「%E3%80%82」=「。」句読点
「%E3%80%83」=「〃」繰返しの記号?
の三文字についても同じように調べてみたところ、全部同一のエラーがでました。隣の「ヂ」とか「、」などはOKでした。

そこで今度は$config['permitted_uri_chars'] の「ぁ-ん」の隣に「む」と記述してみました。「ぁ-んむa-z 0-9~%.:_-」

すると今度は「む」もすんなりと通りました。不思議なことに「ダ」「。」「〃」も通ってしまいました。

う〜む、謎は深まるばかり…。なんとなく%80がダメっぽい気もしないでもないですが、どうも100%そうとも言い切れないような、なんかモヤモヤした気分。そこでUTF-8についてもう少し調べていると、なんかpreg_matchに関して書いてあって、preg系の関数にUTF-8を通すには/uオプションをつけることとかなんとかなってる。

なんだ「/u」って、とおもいPHPのマニュアルを読む。
パターン修飾子 -- 正規表現パターンに使用可能な修飾子
u (PCRE_UTF8)
この修正子は、Perl 非互換な PCRE の機能を有効にします。パターン 文字列は、UTF-8 エンコードされた文字列として処理されます。 この修正子は、UNIX では PHP 4.1.0 以降、Win32 では PHP 4.2.3 以降で 使用可能です。 また、PHP 4.3.5 以降では、パターンの UTF-8 としての妥当性も確認されます。

ふむふむ…ってこんなのプロプログラマじゃなきゃ知らんがな(--;。ってか自分preg_matchとか使ってないし、使ったのはcodeigniter書いた人じゃん。などとぶつぶつ言いながら、↑の方でpreg_matchのエラーが出ていたファイル=/system/libraries/Router.phpの408行目付近を見てみる。

するとなにやらpermitted_uri_charsをごにょごにょしてるっぽいところでpreg_matchがあった。んですでにi修飾子が書かれてる。なのでおもむろにその隣にuって書いて保存。
if ( ! preg_match("|^[".preg_quote($this->config->item('permitted_uri_chars'))."]+$|i", $str))
     ↓
if ( ! preg_match("|^[".preg_quote($this->config->item('permitted_uri_chars'))."]+$|iu", $str))

こんなことしていいのかどうかは全く定かではない。でもとりあえず上で「ぁ-んむ」としていた個所を「ぁ-ん」に戻して再度テスト。

「あ」:もちろんOK
「む」:なんとOK!
「ダ」:エラー
「。」:エラー
「〃」:エラー

というわけで、一応「ぁ-ん」と指定していて、その文字は通ってそれ以外はエラーになったので、一応期待された動作ってことで終了!UTF-8で運用?している人には参考になるかもしれない。漢字も通したい場合は上記のサイト様のところの書式が参考になるとおもいます。但しおそらくその場合もpreg_matchには/u修飾子は必要そうな気がする(漢字は試してないっす)。だけどコアファイル?をいじってるのでくれぐれも自己責任で〜。
ラベル:codeigniter
posted by ciallost at 22:54| Comment(1) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年11月19日

CodeIgniter:ソート機能を付けてみる

前回はチェックボックスの実装?をしてみましたが、今回はテーブル出力された表にソート機能を加えてみたいとおもいます。

できあがりイメージとしては、表の項目部分(つまり<th>タグで出力されているところ)をクリックするとその項目での昇順/降順でソートされる…のようなよくあるパターンのやつです。

手順的には、まずクリックされた項目などからデータベースのどのフィールドをどの方向にソートするかという情報をセッションデータに格納し、index()ではセッションデータを読み出して、データベースクエリにORDER句を用いてソートされたデータを出力…といった感じにします。

そこでまずModelのControllerから呼ばれるgetCountとgetDataにソート項目とソート方向の要素ををもつ配列$sort_keyという引数を一つ増やします。
function getCount($criteria, $sortkey)
function getData($offset, $limit, $sortkey)

次にクエリ組立部分に$sortkeyを使ってORDER句を加えます。
$this->db->orderby($sortkey['field'], $sortkey['order']);

次にControllerにsort()メソッドを追加します。
function sort($field='nid')
{
//ソート方向セット
$order='asc';
$now_order = unserialize($this->phpsession->get('sort_key'));
if ($field == $now_order['field'] && $now_order['order'] == 'asc')
{
$order = 'desc';
}
//ソート条件セット
$this->phpsession->save("sort_key", serialize(array('field'=>$field, 'order'=>$order)));
redirect('gappei/index/');
}

基本的にはsort_keyというセッションデータを格納しているだけですが、ソート方向を決めている部分が若干汚い感じになってしまいました。ロジックとしては基本的には昇順でフィールド名が前回ソートされたフィールド名と同じで向きが昇順だった場合のみ降順に設定します。つまり同じ項目を続けて2回クリックした場合は降順になるということです。そしてindex()で読み出します。
//ソート条件取得
$data['sort_key'] = array('field' => 'nid', 'order' => 'asc');

if ($this->phpsession->get('sort_key') <> '')
{
$data['sort_key'] = unserialize($this->phpsession->get('sort_key'));
}

次にViewですがこれまでは<th>タグを直に打っていましたが、アンカーやソート方向のマークなどを出力しなければならないので、別途関数を作って出力する形に書き直してみました。
function setTableHeading($th, $sort)
{
$th_tag = '';

foreach ($th as $field => $column)
{
$th_tag .= "<th>";
$th_tag .= anchor('gappei/sort/'.$field, $column);
if ($field == $sort['field'])
{
$order_mark = $sort['order']=='asc' ? '▽' : '△';
$th_tag .= " <span id=\"order_mark\">".$order_mark."</span>";
}
$th_tag .= "</th>\n";
}

return $th_tag;
}

引数は実際のデータベースのフィールド名をキーにテーブル出力する際の項目名を値とした連想配列、及びソートフィールドとソート方向を持った連想配列の2つの配列になります。

基本的にはCodeIgniterのanchorメソッドを使ってsort()メソッドを呼び出すリンク付きの項目名を出力しているだけです。ソートに使用されている項目名の場合はソート方向を表すマークも出力します。

そして今まで<th>タグを書いていた所から、上の関数を呼び出して出力する…みたいな感じです。
setTableHeading(array('pref'=>'都道府県',
'nc'=>'新市町村',
'oc'=>'旧市町村',
'gdate'=>'合併日'),
$sort_key);

で、これで一応動作はしますが、旧市町村をソートキーにした場合はうまくありません。これは発行しているクエリの問題で、要するに内側のサブクエリ時点でLIMITをかけている為ORDER句も基本的にはサブクエリ内に書く必要がありますが、旧市町村はサブクエリ時点では拾ってきていないので、その場合だけ外側のクエリでもORDERをかける必要があるということです。なので…

Model(外側のクエリ組立部分)
//旧市町村でソートする場合
if ($sortkey['field'] == 'oc')
{
$this->db->orderby($sortkey['field'], $sortkey['order']);
}

という感じでソートキーが旧市町村の時だけORDER句を生成するようにします。

あとここまでのコードだと一度項目をクリックしてしまうとリセットをかける手段がないので、新たに検索ボタンが押された場合はソート条件をリセットするようにsearch()メソッドに…
//ソート条件初期化(ORDER nid asc)
$this->phpsession->save("sort_key", serialize(array('field'=>'nid', 'order'=>'asc')));

を追加します。これは実際にはリセットというよりnid(=新市町村のID)で昇順にソートしています。

表示結果
sort.png

なんとなく駆け足で実装してしまいましたが、果たしてこんなのでいいのか?という不安はどこまでいってもついて回ります(--;特にViewファイルにfunctionを作ってそれを呼び出しているところなんかは、本当にここに書いてもいいのか?感がひとしおです。でも一応こんな適当な実装でも動くってことで…。
ラベル:codeigniter
posted by ciallost at 20:45| Comment(2) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年11月18日

CodeIgniterでcheckboxを作ってみる

前回までで検索フィールド×3、表示件数変更ドロップダウンメニュー、検索結果のページングを実装した、なんちゃって検索アプリ風味(謎)なモノができあがりました。前回と前々回は多少詰め込みすぎた?というかBlogが若干長めになってしまいましたが、これには一応わけがあって、実は前回までで実際に作っている(手続き型スクリプトをCodeIgniterで書き直している)プログラムにBlogが追いついたのです。

なので今回からはネタにしているこのサンプルプログラム的には新機能の追加ということになります。新機能といっても、大元の(本来こっちをとっととCodeIgniterで書き直したい謎の)スクリプトには実装されているようなごく一般的なもので、CodeIgniterで書くにはどうしたらいいだろう?といったことを、今回からはスクリプト作成と平行してBlogに綴っていく予定です。

ということで今回はcheckboxです。DropDownFieldと同じくGUI部品だけ作ってもアレなんで、今回は表示モード?を切替えてみたいとおもいます。

これまでは都道府県・新市町村・旧市町村・合併日というカラムのテーブルを表示していましたが、新たに新市町村・合併日だけのカラムにした簡易表示?みたいなモノを出力することにして、それと通常表示?詳細表示(つまり今までの表示ですが)を切替える機能を加えます。

表示イメージ
disp_simple.png
まあこの表示がユーザにとって必要かどうかということはさておき(サンプルですから)、とにかくやりたいことは、表示状態?をcheckboxで切替えるってことです(^^;

そこでまず「表示」ということで前回の表示件数変更を切替えた際のformを利用しようと考えました。利用というか同じformにcheckbox作って、検索条件をセッションに格納した時と同じように配列にしてcookieに入れて…と考え実際に書いてみましたが、結論から言うとそのやり方では二重に失敗?しました(--;

失敗した経緯を詳しく書こうともおもったのですが、無駄に長くなるので、どこがダメだったかだけメモしておきます。

まずセッションで配列を格納する際にはserialize()を使っていましたが、cookieではこれがどうもうまく機能しなかったのです。具体的にはpost配列をserialize化することは強引に?できるみたいなのですが、それを読み出す時点で、もちろんunserialize()を使いますが、何故かうまくいきませんでした。

またPHPのマニュアルによると…
クッキー名で配列を記述することにより、 クッキーの配列を設定することも可能ですが、複数のクッキー がユーザーのシステム上に保存されることになります。 explode() を使用して ひとつのクッキー上に複数の名前と値をセットすることも 考慮してください。serialize() の使用はセキュリティーホールになり得るため、 この目的のために使用することは推奨されません。

ということなので、いずれにしろserializeは却下されました。

そして単独で、つまり表示件数の数値とは別に、cookieに保存してやればまあいいだろうとおもい、そのようなコードを書いたのですが、今度はcheckbox特有の動作によってうまくいきませんでした(--;。

この「checkbox特有の動作」はおそらくwebのプログラムを書いた人は一度はハマルんではないかとおもいますが、要するに…
checkboxがチェックされていないときはpostデータが送られてこない

という仕様のことです。

もう少し具体的にいえば、例えばテキストフィールドの場合だと<input type="text" name="hoge"〜のようになっていれば例え入力が空でも$_POST['hoge']という変数自体は存在するわけですが、これがチェックボックスだと<input type="checkbox" name="hage"〜のようになっていても、チェックされていない場合は$_POST['hage']という変数自体がないわけです。

なので不用意に if ($_POST['hage'] == TRUE)〜のようなコードを書いてしまうと、そもそも$_POST['hage']がないので、hageって何?みたいなエラーになってしまうわけです。

そして性質が悪い?というかやっかいなのは、↑のようになる場合はあくまでもチェックが外された状態でサブミットされた時であって、チェックを付けてサブミットされた時は何の問題もなかったかのように動いてしまったりすることです。

チェックボックスのこのような挙動は一見不合理なように見えますが、実は複数のチェックボックスをラジオボタン的に使う場合などは便利かもしれません。ラジオボタンだとどれか一つは必ず選択状態になりますが、チェックボックスだとどれも選択しないということができるからです。

ただ今回の場合のように単に真偽値だけを受取りたい場合は、その他のform部品とはちょっと違った挙動だということを考慮しないと、はまる原因になったりします。(ってか実際はまってます)

というわけで冗長的な感がないでもありませんが、メソッドを一つ作ることにして、前回の表示件数とは別に処理するような形にしました。またViewをヘッダ部・フォーム部・メイン部・フッタ部に分けて、共通部分はView内で別のViewを呼び出す形にし、新たにメイン部を2つ作りそれを切替えるようにControllerに記述しました。

View
<?$this->load->view('header');?>
<?$this->load->view('form');?>
<hr>
<?if($count > 0):?>
※この部分でデータをテーブルタグで出力
※gappei_detail.phpとgappei_simple.phpの2通りのViewを作成
<?else:?>
<p>該当データがありませんでした。</p>
<?endif;?>
<hr>
<?=$pagination?>
<?$this->load->view('footer');?>


Controller
function changeDispMode()
{
set_cookie('dispmode', $this->input->post('dispmode'), Gappei::COOKIE_LIFE);
redirect('gappei/index/');
}

結果的にchangeDispNumber()とほとんど同じになっていますが、挙動はかなり違います。ポイントは$_POST['dispmode']を直接受取らないで、CodeIgniterのライブラリ経由にすることで変数が存在しなくても(チェックが外されてpostされた場合)エラーにはならないという部分です。さらに(チェックが外されてpostされた場合は)空のデータをcookieにセットしているので、cookie自体が消去されます。つまりtureとかfalseとかの値がdispmodeというcookieに格納されるのではなく、falseの場合はそもそもcookieが削除されるということです。
//表示モード取得
$data['dmChecked'] = (get_cookie('dispmode') <> '') ? get_cookie('dispmode') : FALSE;

//表示viewロード
if ($data['dmChecked'] == TRUE)
{
$this->load->view('gappei_detail', $data);
} else {
$this->load->view('gappei_simple', $data);
}
ラベル:codeigniter
posted by ciallost at 15:02| Comment(0) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする

2007年11月17日

CodeIgniter:DropDownFieldとcookie

前回は検索フィールドを複数化するとともに、細かい部分を修正しました。で、エラーは出ないなぁと思っていたところ、サーバ側に保存されているセッションデータを手動で消去して、ブラウザをリロードしてみたら…

error.png

こんなエラーが!要はセッションデータがないので$data['criteria']がワカンネーヨ!ということなので、初期化することにしました。

Controller
$data['criteria'] = array('nc'=>'', 'oc'=>'');

これで一応は大丈夫なのですが、配列のキーを指定してやらなければならないところがちょっと自分的には気に入らないっつーか、なんか他にどうにかならないもんですかねぇ?

それはさておき、前回は旧市町村名で検索できるようにしたわけですが、ついで?に都道府県名でも検索できるようにしたいとおもいます。

View
都道府県:<?=form_input('condition[pref]', $criteria['pref'])?><br />

Controller
$data['criteria'] = array('pref'=>'', 'nc'=>'', 'oc'=>'');

これだけで動きます(^^)v が、↑の初期化コードさえなくなれば、formにテキストフィールド作っただけでOKだったのにな…。

pref.png

まあ当初の状態よりかなりマシになってきたので、↑の問題はおいおい考えることとして、今回はDropDownFieldを作ります。

で、DropDownFieldってなんぞ?なんですが、ユーザガイドにそう書いてあったのであえて使ってます。これって一般名称ないんですかね?PullDownList?DropDownMenu?PullDownMenu?なんかわかんないけどそんな感じのやつです。要するにselectとoption羅列みたいな…。

んでGUI部品だけ作ってもしょうーもないんで、1ページの表示件数をユーザが選択できるようにしてみたいとおもいます。ユーザガイドによると…
form_dropdown(名前, 選択項目リスト, 選択済み項目, javascript)
みたいな感じらしいので、とりあえずViewに
<?=form_open('gappei/changeDispNumber')?>
<?=form_dropdown('dispnum', $dnOptions, $dnSelect, 'onChange="submit();"');?>
<noscript><?=form_submit('changeDisp', '変更')?></noscript>
</form>

新たなformとして作成します。formの送信先?はchangeDispNumberというメソッドを呼ぼうって魂胆です(^^;一応javascriptで選択されたらサブミットという動きにしましたが、非javascript環境も考慮して<noscript>タグにサブミットボタンを配置しました。

で、Controllerを作る前に悩んだんですが、結局この表示件数というパラメータもどこかに保存しておかないと、検索の時に散々悩んだ検索条件の保存と同じような現象(つまり選択された直後はその表示件数になっても、ページングリンクとか検索サブミットがなされた瞬間リセットされてしまう)が起きるだろうということです。

んで、検索条件はPHPのセッション機構でやりましたが、この表示件数というパラメータを考えたときに、セッションよりもクッキーの方が向いてる?というかユーザ側で保存(しかも比較的長期)した方がいいんじゃないの?と考えました。実際多くの検索エンジンはそうなってると思います。

というわけでCodeIgniterのcookieヘルパーをざっと調べて、ほとんどPHPの組込だな!とかおもって、おもむろにController
$this->load->helper('cookie');

まずヘルパーロード。これはコンストラクタの中に。そして新しいメソッド…
function changeDispNumber()
{
set_cookie('dispnum', $this->input->post('dispnum'), Gappei::COOKIE_LIFE);
redirect('gappei/index/');
}

このメソッドは表示件数のDropDownFieldが選択されると呼び出されcookieにその値を保存します。COOKIE_LIFEはclassの上のほうで
const COOKIE_LIFE = 2592000;

のように定義しました。果たしてこんなところに書いていいのかどうかは毎度のことながら不明(不安)。

んで、後はindex()の方にcookie読み出しと選択項目リストを定義します。
//表示件数取得
$data['dnSelect'] = (get_cookie('dispnum') <> '') ? get_cookie('dispnum') : 10;

デフォルトは10件ということになります。そしてリスト…
//表示件数メニューオプション
$data['dnOptions'] = array('10'=>'10',
'20'=>'20',
'30'=>'30',
'50'=>'50',
'100'=>'100'
);

表示結果
dispnum.png

上の方はDropDownFieldで20件を選択した様子です。データもきちんと20件表示されています(画面ではわかりませんが)。んで下のHTMLソースみたいなものはページングリンクの部分です。見てのとおり20件毎にリンクが生成されているのがわかるとおもいます。
ラベル:codeigniter
posted by ciallost at 02:59| Comment(3) | TrackBack(0) | 日記 | このブログの読者になる | 更新情報をチェックする