ADOdbでExecuteを使う時に、?を使って値を変換してSQLを実行してくれるっていう使い方がある。
$conn->Execute("SELECT * FROM TABLE WHERE COND=?", array($val));

みたいな。

で、自分はテーブル名も入れたかったので、こうやった。
$conn->Execute("SELECT * FROM ? WHERE COND=?", array($table, $val));

まあ、テーブル名を追加しただけなんだけど、これが上手く動かない。
エラーメッセージには文法的に違うとしか出てこなくて、途方にくれた。

みんな、出来ているようなのになぜ?ということで、休みの日を使って調べてみた。
で、結局分かったのが、こんなSQLを発行されていた。
"SELECT * FROM 'tablename' WHERE COND='1'";

ここで、tablenameを"'"で囲っていることが気になり、それを外してみるとビンゴ!

以前、ADOdbを調べた時にデータベースのソフトによって、何で囲うかは判別していたような気がしたので、今回はもっと深いところまで追ってみた。
まず囲う文字のことを「識別子引用符文字」ということ。
なげえ。

で、一応qstr()って囲ってくれる関数はあるんだけど、それはMySQL独自の文字で囲うってよりもカラムの値を囲む為のもので、テーブル名とかまでは考慮していない模様。
テーブル名とかそういうのは、nameQuoteって値に入っているみたいだけど、それで囲ってくれるような関数は用意されていないっぽい。
(内部ではあるみたいだけど、外からは参照できなかったかな)

なんで直接その値を参照すれば良い。

まあ、見栄えは悪いけど様はこういうことだ。
$conn->Execute(
"SELECT * FROM ".$conn->nameQuote.$tablename.$conn->nameQuote. " WHERE ".$conn->nameQuote."COND".$conn->nameQuote."=?", array($val));


ちなみにEthnaのEthna_DB_ADOdbクラスでは、quoteIdentifier()って関数が用意されているので、楽チン。
$this->db->query(
"SELECT * FROM ".$this->db->quoteIdentifier($tablename). " WHERE ".$this->db->quoteIdentifier('COND')."=?", array($val));


自分の気づく遅さが嫌になる&またまた勉強になった。
Ethnaすげえ。
Ethna 2.5.0 preview2のテストがてらsimpletestを本腰を入れて使ってみた。

ActionClassとActionFormのテストは比較楽に出来たんだけど、AppManagerのテストする時に躓いた。
これをわかっていないといろいろ面倒なので、整理してメモしておこうと思う。

あまりsimpletestのガッツリとした例を見たことが無いのでなんともいえないけど、一つのテストケースクラスで何度もテストを実施するっていのが通常の使い方のように思う。
function test_a()
{
}
function test_b()
{
}
function test_c()
{ }

こんな感じ。

AppObjectのexportFormとか頻繁に使っていたので、ActionForm($this->af)が結構重要な役割を占めていた僕としては、テストの中でも$this->afを使いたいと思っていた。
で、EthnaのUnitTestCaseクラスのソースを見てみても、そうやって使ってもらおう為にいろいろ用意してくれている感じもしたし。
素直に使おうと思ってはみたもののAppManagerクラスのテストは特にどのActionFormクラスを使えば良いというのも無いんだよな。
どうしようかなとソースを見てみると、便利そうなcreatePlainActionForm()って関数があった。
どうやら空のActionFormを作ってくれるみたいなので、これを使うことにした。

あとAppManagerクラスのテストとなると、何度もAppManagerを呼ばないとテストにならないわけで、そんならコンストラクタで呼べばええのかなと思ったんだけど、この辺がなかなか難しい。
結論をいうと、setUpで呼ぶことにした。

ということで、僕のsetUpとtearDownはこんな感じになった。
var $um;
var $testdata;

/**
* initialize test.
*
* @access public
*/
function setUp()
{
$this->session->start(); // start session.
$this->createPlainActionForm(); // create ActionForm

// get manager
$this->um =& $this->backend->getManager('Users');
$this->um->action_form =& $this->af;
$this->um->af =& $this->af;

// add test user
$this->af->set('id', 100);
$this->af->set('email', 'hoge@hoge.com');
$this->af->set('password', 'abcde');
$this->af->set('role', 'general');

// add test data
$this->um->addData();

// copy test data
$this->testdata['users'] = $this->af->getArray();

// form data clear
$this->af->clearFormVars();
}

/**
* clean up testcase.
*
* @access public
*/
function tearDown()
{
$this->session->destroy(); // destroy session.

// delete test user
$this->um->deleteData($this->testdata['users']['id']); }

まずは最初の2行。
$this->session->start();        // start session.
$this->createPlainActionForm(); // create ActionForm

セッションをスタートさせているのは、AppManagerクラスで使っているところがある為、使ってない人はいらない。
あと、createPlainActionFormでプレーンなActionFormクラスを生成&エラー周りのデータもクリアしている。
このクリアしているってことを、ちゃんと把握することに時間がかかった。

で、次。
$this->um =& $this->backend->getManager('Users');
$this->um->action_form =& $this->af; $this->um->af =& $this->af;

一番のポイントはここ!
特に後の2行、これかなり重要。

というのも、先ほどcreatePlainActionFormで値をクリアしているといったが、それはこのUnitTestCase内のActionFormだけで、AppManagerクラスやAppObjectクラスのデータはクリアされないことがわかった。
つまり
$this->af !== $this->um->af

となってしまうんだよね。
ソースを見る限り、参照渡しをしているからAppManager側もクリアされても良いもんだと思うんだけど、実際はされていない模様。
いろいろ調べてみたら、AppManagerクラスやAppObjectクラスは、1回のリクエストの間に何度も呼ばれた場合、2回目以降は1回目呼ばれた時に生成したキャッシュの値を持ってくる仕組みになっているっぽい。
で、実際動かしてみると、1回目のテスト(例えば今回だとfunction test_a)まではちゃんと同期は取れているんだけど、2回目から途端に駄目になり、1回目のActionFormの値がAppMnager側には残ってしまう状態になってしまってる。

多分、クリアの仕方に何か原因がありそうなんだけど、そこまでは追いきれなかった。
ううう。

ということで、解決策としては、setUp()毎にcreatePlainActionForm()やcreateActionForm()を呼んで、その度にActionFormがクリアされたものとしたい場合は、その都度コピーしないといけないってことになる。
で、それがこの部分というわけ。

ふぅ・・・。
後から読み直して自分でも理解できるだろうか・・・。

後の部分は、毎回同じデータがDBにあるという前提でテストをしたいから、毎回テストデータを追加&削除を行っているだけ。

これで、
・毎回ActionFormの値はクリアされている
・DBにも同じデータが入っている
・通常の運用と同じようにActionFormの値がAppManagerクラスにもちゃんと引き継がれる
・AppManagerクラスで変更した場合でも、ちゃんとActionClass(ここではUnitTestCaseクラス)にも反映されて返ってくる
という状態になるので、初めての人でもそんなに違和感無く出来るのでは?と思う。
少なくとも僕はやりやすくなったんだけど。

非常に負荷のかかるやり方をしてしまってはいるとは思うんだけど、その分頭の中はすっきり整理出来るし、今のところはこれで良いのかなと思ってる。
このやり方で何度かテストを行ってるけど、そんなに大きな問題もないし。
まあ、全部のテストを一気にやると、なかなかレスポンスが返ってこなくなっちゃうってことぐらいかな。

この例は特に参考にするもの無く、自分でこんな感じかな?と思って手探りでやった結果なので、正しいやり方じゃないかもしれないけど、あまりネット上にもEthnaでのsimpletestの実例自体が見ないし、特にAppManagerクラスのテストとなると見たことが無かったので晒してみた。

参考になれば是幸い。
らしいです。

これからのプログラムの作り方 - 文字エンコーディング検証は必須

mb_check_encoding関数が使えるのはPHP 4.4.3以降らしいので、それ以前の場合はmb_convert_encoding関数を使用すれば良い模様。

mb_convert_encoding関数を使用し、変換前と変換後の文字エンコーディングに同じ文字エンコーディングを指定し、変換前と変換後の文字列に変化があるかを確かめる事により不正な文字エンコーディングを検出できます。


けど、UTF8だと同じUTF8でも違う結果を出力する場合も有るんだよなあ。
ハイフンとかその辺りだった気がするが・・・。

まあ、その辺はセキュリティとのトレードオフってことになるんだろうね。

※2008/09/02追記
Ethna 2.5.x用のプラグインを作ってみた。
エラーメッセージの英語に自信ないけど、その辺はご愛嬌で。

app/plugin/Validator/Hoge_Plugin_Validator_Charenc.php

class Hoge_Plugin_Validator_Charenc extends Ethna_Plugin_Validator
{
/** @var bool 配列を受け取るかフラグ */
var $accept_array = false;

/**
* 文字エンコーディングのチェックを行う
*
* @access public
* @param string $name フォームの名前
* @param mixed $var フォームの値
* @param array $params プラグインのパラメータ
*/
function &validate($name, $var, $params)
{
$true = true;
$type = $this->getFormType($name);
if (isset($params['charenc']) == false || $this->isEmpty($var, $type)) {
return $true;
}

// define call back function for array_walk()
if (function_exists('mb_check_encoding')) {
// mb_check_encoding exists. just use it
if (!mb_check_encoding($var, mb_internal_encoding()) {
if (isset($params['error'])) {
$msg = $params['error'];
} else {
$msg = _et('Please input character string
of correct character encoding to {form}.');
}
return Ethna::raiseNotice($msg, E_FORM_ENCORDING_STRING));
}
} else {
// mb_check_encoding does not exist. use mb_convert_encoding()
$val = mb_convert_encoding($var, $encoding, $encoding);
if (!($val === $this->form_vars[$name])) {
if (isset($params['error'])) {
$msg = $params['error'];
} else {
$msg = _et('Please input character string
of correct character encoding to {form}.');
}
return Ethna::raiseNotice($msg, E_FORM_ENCORDING_STRING));
}
}

return $true;
} }


あと、エラーコードも追加。
Hoge_Error.php
define('E_FORM_ENCORDING_INVALID', 301);


一回も動かしてないけど、こんな感じでいけるかな?
なるべく上の方に持っていけば、そのタイミングでチェックしてくれると思うけど、typeが一番最初に来てしまうことも注意。
きになるなら、そこまで手を加える必要がある。
それ程こだわりも無いんだけど、長いこと放置されてきたような気もするし、ADOdb使おうかなと思ったのでやってみた。
結果的にいうと「自分にしては結構大変だわ」でした。

以下、オーバーライドして使うHoge_DB_ADOdbクラスのソースですよ。
require_once 'Ethna/class/DB/Ethna_DB_ADOdb.php';

// PEAR:DBに合わせる為(これがないとnotice出します)
define('DB_FETCHMODE_DEFAULT', null);
define('DB_FETCHMODE_ASSOC', null);

/**
* Hoge_DB_ADOdb
*
* EthnaのフレームワークでADOdbオブジェクトを扱うための抽象クラス
*
* @package Ethna
* @author longkey1
* @access public
*/
class Hoge_DB_ADOdb extends Ethna_DB_ADOdb
{
/**#@+
* @access private
*/

/** @var object Ethna_Logger ログオブジェクト */
var $logger;

/** @var object Ethna_AppSQL SQLオブジェクト */
var $sql;

/** @var string DBタイプ(mysql, pgsql...) */
var $type;

/** @var array DSN (DB::parseDSN()の返り値) */
var $dsninfo;

/**#@-*/

/**
* コンストラクタ
*
* @access public
* @param object Ethna_Controller &$controller コントローラオブジェクト
* @param string $dsn DSN
* @param bool $persistent 持続接続設定
*/
function Hoge_DB_ADOdb(&$controller, $dsn, $persistent)
{
parent::Ethna_DB_ADOdb($controller, $dsn, $persistent);

$this->db = null;
$this->logger =& $controller->getLogger();
$this->sql =& $controller->getSQL();

$this->dsninfo = $this->parseDSN($dsn);
$this->dsninfo['new_link'] = true;
$this->type = $this->dsninfo['phptype'];
}

// {{{ getType
/**
* DBタイプを返す
*
* @access public
* @return string DBタイプ
*/
function getType()
{
return $this->type;
}
// }}}


// {{{ getMetaData
/**
* テーブル定義情報を取得する
*
* @access public
* @param string $table テーブル名
* @return mixed array: PEAR::DBに準じたメタデータ Ethna_Error::エラー
*/
function &getMetaData($table)
{
$def =& $this->db->MetaColumns($table);
$deforg = $def;// originalとして退避

if (is_array($def) === false) {
return $def;
}

foreach (array_keys($def) as $k) {
// object型で返してくるので
$def[$k] = array_map('strtolower', (array)$def[$k]);

// type
$type_map = array(
'int' => array(
'int', 'integer', '^int\\(?[0-9]\\+', '^serial', '[a-z]+int$',
),
'boolean' => array(
'bit', 'bool', 'boolean',
),
'datetime' => array(
'timestamp', 'datetime',
),
);
foreach ($type_map as $convert_to => $regex) {
foreach ($regex as $r) {
if (preg_match('/'.$r.'/', $def[$k]['type'])) {
$def[$k]['type'] = $convert_to;
break 2;
}
}
}

// len
if($def[$k]['max_length'] > 0){
$def[$k]['len'] = $def[$k]['max_length'];
}

// flags
if($def[$k]['primary_key'] == true){
$def[$k]['flags'][] = 'primary_key';
}
if($def[$k]['not_null'] == true){
$def[$k]['flags'][] = 'not_null';
}
switch ($this->type) {
case 'mysql':
case 'pgsql':
// カウンタあるいは自動インクリメントフィールドであれば sequence
if ($this->db->MetaType($deforg[$k], null, true) == 'R') {
$def[$k]['flags'][] = 'sequence';
break;
}
break;
// ADOdbの対応次第かなあ、今のところちと厳しい
// case 'sqlite':
// break;
}

}

return $def;
}
// }}}

// {{{ qstr
/**
* 文字列をクオートする
*
* @access public
* @param string $s クオートする文字列
* @return string クオートされた文字列
*/
function qstr($s, $magic_quotes=false)
{
return $this->db->qstr($s,$magic_quotes);
}
// }}}

// {{{ Ethna_AppObject連携のための実装
// {{{ getNextId
/**
* 直後のINSERTに使うIDを取得する
* (pgsqlのみ対応)
* ※adodbでは特に対応していないのでnullを返す、代わりにgetInsertIDを対応させた
*
* @access public
* @return mixed int
*/
function getNextId($table_name, $field_name)
{
return null;
}
// }}}

// {{{ getInsertId
/**
* 直前のINSERTによるIDを取得する
* (mysql, pgsql, sqlite対応)
* ※pgsql用に引数を追加
* @access public
* @return mixed int:直近のINSERTにより生成されたID null:未サポート
*/
function getInsertId($table_name = null, $field_name = null)
{
if ($this->isValid() == false) {
return null;
} else if ($this->db->Insert_ID($table_name, $field_name) == false) {
return null;
}
return $this->db->Insert_ID($table_name, $field_name);
}
// }}}

// {{{ fetchRow
/**
* DB_Result::fetchRow()の結果を整形して返す
*
* @access public
* @return int 更新行数
*/
function &fetchRow(&$res, $fetchmode = DB_FETCHMODE_DEFAULT, $rownum = null)
{
$row =& $res->FetchRow();
if (is_array($row) === false) {
return $row;
}

if ($this->type === 'sqlite') {
// "table"."column" -> column
foreach ($row as $k => $v) {
unset($row[$k]);
if (($f = strstr($k, '.')) !== false) {
$k = substr($f, 1);
}
if ($k{0} === '"' && $k{strlen($k)-1} === '"') {
$k = substr($k, 1, -1);
}
$row[$k] = $v;
}
}

return $row;
}
// }}}

// {{{ affectedRows
/**
* 直近のクエリによる更新行数を取得する
*
* @access public
* @return int 更新行数
*/
function affectedRows()
{
return $this->db->Affected_Rows();
}
// }}}

// {{{ quoteIdentifier
/**
* dbのtypeに応じて識別子をquoteする
* (配列の場合は各要素をquote)
*
* @access protected
* @param mixed $identifier array or string
*/
function quoteIdentifier($identifier)
{
if (is_array($identifier)) {
foreach (array_keys($identifier) as $key) {
$identifier[$key] = $this->quoteIdentifier($identifier[$key]);
}
return $identifier;
}

$ret = $this->db->nameQuote . $identifier . $this->db->nameQuote;
return $ret;
}
// }}}

// {{{ sqlquery
/**
* SQL文指定クエリを発行する
*
* @access public
* @param string $sqlid SQL-ID(+引数)
* @return mixed DB_Result:結果オブジェクト Ethna_Error:エラー
*/
function &sqlquery($sqlid)
{
$args = func_get_args();
array_shift($args);
$query = $this->sql->get($sqlid, $args);

return $this->_query($query);
}
// }}}

// {{{ sql
/**
* SQL文を取得する
*
* @access public
* @param string $sqlid SQL-ID
* @return string SQL文
*/
function sql($sqlid)
{
$args = func_get_args();
array_shift($args);
$query = $this->sql->get($sqlid, $args);

return $query;
}
// }}}
// }}} }


まあ、こんな感じ?
今のところちゃんと動いていたEthna_DB_PEARクラスを超参考にしてあるので、自分では使わないメソッドてんこ盛り。
そこはテストもしてないので、ようわかりません。
ポイントとしては、
・getNextId()はADOdbではサポートしてないので、実質放棄
・getInsertId()にpgsqlを対応させるため、引数を変更
・getMetaData()のsqliteはADOdbではサポートしてないので放棄
sqliteに関しては、ちょっと絶望的かもなあ。


あと、Ethna_AppObjectクラスもオーバーライドするのでした。

// {{{ Hoge_AppObject
/**
* アプリケーションオブジェクトのベースクラス
*
* @package Hoge
* @author longkey1
* @access public
*/
class Hoge_AppObject extends Ethna_AppObject
{
// {{{ add
/**
* オブジェクトを追加する
*
* @access public
* @return mixed 0:正常終了 Ethna_Error:エラー
*/
function add()
{
// next idの取得: (pgsqlの場合のみ)
// 取得できた場合はこのidを使う
// ※ADOdb対応でnext id取得できなくなったのでいらないかもだけど、
// とりあえず残しておく
foreach (to_array($this->id_def) as $id_def) {
if (isset($this->prop_def[$id_def]['seq'])
&& $this->prop_def[$id_def]['seq']) {
// NOTE: このapp object以外からinsertがないことが前提
$next_id = $this->my_db_rw->getNextId(
$this->prop_def[$id_def]['table'], $id_def);
if ($next_id !== null && $next_id >= 0) {
$this->prop[$id_def] = $next_id;
}
break;
}
}

$sql = $this->_getSQL_Add();
for ($i = 0; $i < 4; $i++) {
$r =& $this->my_db_rw->query($sql);
if (Ethna::isError($r)) {
if ($r->getCode() == E_DB_DUPENT) {
// 重複エラーキーの判別
$duplicate_key_list = $this->_getDuplicateKeyList();
if (Ethna::isError($duplicate_key_list)) {
return $duplicate_key_list;
}
if (is_array($duplicate_key_list)
&& count($duplicate_key_list) > 0) {
foreach ($duplicate_key_list as $k) {
return Ethna::raiseNotice('Duplicate Key Error [%s]',
E_APP_DUPENT, $k);
}
}
} else {
return $r;
}
} else {
break;
}
}
if ($i == 4) {
// cannot be reached
return Ethna::raiseError('Cannot detect Duplicate key Error', E_GENERAL);
}

// last insert idの取得: (mysql, postgres, sqlite)
// primary key の 'seq' フラグがある(最初の)プロパティに入れる
// ※ pgsql用にテーブルとカラム名をセットするように変更
foreach (to_array($this->id_def) as $id_def) {
if (
(isset($this->prop_def[$id_def]['seq'])) &&
($this->prop_def[$id_def]['seq'])
) {
$insert_id = $this->my_db_rw->getInsertId(
$this->prop_def[$id_def]['table'],
$id_def
);
if ($insert_id !== null && $insert_id >= 0) {
$this->prop[$id_def] = $insert_id;
}
break;
}
}

// IDの設定
if (is_array($this->id_def)) {
$this->id = array();
foreach ($this->id_def as $k) {
$this->id[] = $this->prop[$k];
}
} else if (isset($this->prop[$this->id_def])) {
$this->id = $this->prop[$this->id_def];
} else {
trigger_error("primary key is missing", E_USER_ERROR);
}

// バックアップ/キャッシュ更新
$this->prop_backup = $this->prop;
$this->_clearPropCache();

return 0;
}
// }}}

} // }}}


これで最低限動くかなって感じだ。
next idが絡むところは、全部削除しても良いかも。
無理してsql直書きでってやり方もあるだろうけど、それはADOdbとは完全に違うものになってしまうしなあ。
どうなんだろうね。

とりあえず、ネット上にこういう情報がほぼ皆無だったので、晒しておきます。

※ 2008/10/08 追記
以下を最初の方に追記しました。
define('DB_FETCHMODE_DEFAULT', null);
define('DB_FETCHMODE_ASSOC',   null);

実働には支障が無い(使ってない)のですが、Noticeだしてsimpletest使う時とかにやたらexceptionsを出すので。
EthnaのEthna_AppObjectクラスはなかなか癖はありますが、ちょろっとした小規模のシステムの場合重宝する。
やっぱ楽なので。
古い環境(MySQL3.23.xとか)の場合、ちょっとばかり面倒な箇所があるので、こんなケースレアかもしれないけど、僕にはまだまだ必要なので晒しておく。

MySQLの3と4ではLIMIT/OFFSET句の書き方が違い、そこでこける。

ということで、Ethna_AppObject又はオーバーライドしたクラスの_getSQL_SearchProp()って関数を修正。
 // LIMIT, OFFSET
$limit = "";

-if (is_null($count) == false) {
- $limit = sprintf("LIMIT %d", $count);
- if (is_null($offset) == false) {
- $limit .= sprintf(" OFFSET %d", $offset);
- }
-}

+if (is_null($count) == false) {
+ // mysqlの4.0.0未満かどうかのチェック
+ if (
+ ($this->db->type == "mysql") &&
+ (version_compare(
+ preg_replace('|[^0-9\\.]|', '', @mysql_get_server_info()),
+ '4.0.0',
+ '<')
+ )
+ // mysqlの4.0.0未満の場合
+ ) {
+ if (is_null($offset) == false) {
+ $limit = sprintf("LIMIT %d", $offset).sprintf(", %d", $count);
+ } else {
+ $limit = "LIMIT 0".sprintf(", %d", $count);
+ }
+ // それ以外の場合(デフォルトのソースのまま)
+ } else {
+ $limit = sprintf("LIMIT %d", $count);
+ if (is_null($offset) == false) {
+ $limit .= sprintf(" OFFSET %d", $offset);
+ }
+ } +}


MySQL以外のDBの時のは検証してないけど、多分いけるはず。
まー、新規で開発する場合はいらないけどね。

version_compare()とmysql_get_server_info()なんて関数があることにびっくり。
勉強になった。