PL/rb: Ruby手続き言語 (PostgreSQL函数の記述)

概要: PL/rbの目的、特徴

PostgreSQL付属ドキュメントの「外部プロジェクト」節にはPL/Rubyの記載がある (ただしリンク切れ)。その意味ではいわゆる車輪の再発明であるが、長らく保守されていない様子であること、適用に当たって細部を修正する必要が生じたことを考慮し、独自に開発することにした。

ダウンロード

plrb-0.5.2.tar.xz

インストール、設定

$ ruby extconf.rb
$ make
# cp plrb.so /usr/local/pgsql/lib

次にデータベースを生成し、PL/rbハンドラーを登録する。

$ createdb db_plrb
$ psql -d db_plrb
db_plrb=# CREATE FUNCTION plrb_call_handler() RETURNS language_handler
db_plrb-#   AS '$libdir/plrb'
db_plrb-# LANGUAGE 'c';
db_plrb=# CREATE FUNCTION plrb_inline_handler(internal) RETURNS void
db_plrb-#   AS '$libdir/plrb'
db_plrb-# LANGUAGE 'c';
db_plrb=# CREATE FUNCTION plrb_validator(oid) RETURNS void
db_plrb-#   AS '$libdir/plrb'
db_plrb-# LANGUAGE 'c';
db_plrb=# CREATE LANGUAGE plrb
db_plrb-#   HANDLER plrb_call_handler
db_plrb-#   INLINE plrb_inline_handler
db_plrb-#   VALIDATOR plrb_validator;

ハンドラー函数は3つあるが、手続き言語ハンドラー (plrb_call_handler) が中心である。

有効性検証函数 (plrb_validator) は、「CREATE FUNCTION」実行時に構文検査をおこなう。インライン・ハンドラー (plrb_inline_handler) は、SQLコマンド「DO」で記述した無名コード・ブロックを実行する。

「CREATE EXTENSION」コマンドで登録する方法については、pg_pltemplateカタログの取り扱いが変わる過渡期であるため、保留にしている。PostgreSQL付属ドキュメントの「システム・カタログ」章、「pg_pltemplate」節には、「今後のPostgreSQLのリリースでpg_pltemplateは削除される予定」である旨の記載がある。

簡単な函数の定義と実行

CREATE FUNCTION named_operator(num integer, op text) RETURNS text AS
$$
	case op
	when nil
		return "Operator is NULL";
	when 'succ'
		return "#{num} -> #{num + 1}";
	when 'pred'
		return "#{num} -> #{num - 1}";
	when 'double'
		return "#{num} -> #{num * 2}";
	else
		return "Unknown Operator #{op}";
	end
$$ LANGUAGE 'plrb';

このように、函数の定義部分はSQL、実装部分はrubyの構文で記述する。これで函数が定義されたので、SQL文中に埋め込んで呼び出すことができる。SQLの文字列定数は、二重引用符ではなく単一引用符で括ることに註意する。

SELECT	named_operator(7, 'pred') AS result;	-- "7 -> 6"

(SQLの) NULLを渡すと、ruby側ではnilになる。

SELECT	named_operator(7, NULL) AS result;	-- "Operator is NULL"

スクリプト・エンコーディング

函数の定義部分にはUTF-8の文字列を記述できる。postgresql.confに、

	plrb.script_encoding = 'EUC-JP'

のように記述すれば、このスクリプト・エンコーディングを変更できる。

型の変換

PostgreSQL側とruby側で引数や戻り値をやり取りする際、次のように型が変換される。

PostgreSQLruby備考
bool、booleanFalseClass/TrueClass
int2、smallintInteger
int4、integer
int8、bigint
float4、realFloat
float8、double precision
numeric、decimalBigDecimal
charString
varchar
text
bytea
dateDate
timeTimeTimeの日付部分は無視
timestampDateTime
timestamptz
intervalString「15 years 1 mon 2 days」のような書式の文字列
bitString「011010」のように「1」と「0」から成る文字列
varbit

ruby側のStringは、エンコーディングがUTF-8になる (例外としてbyteaの場合はASCII-8BIT)。とは言っても何らかの変換を施すわけではなく、String#force_encoding()でエンコーディング情報を変えるだけである。これはpostgresql.confに

	plrb.string_force_encoding = 'EUC-JP'

のように記述することにより変更できる。

「time with time zone」型については、あえて考慮していない。この型は機能に問題があるので使用を推奨しない旨、PostgreSQL付属ドキュメントの「日付/時刻データ型」節に記載がある。

rubyのTimeクラスは、1970~2038年の範囲しか扱えない (システム環境によってはもっと広い範囲を受け付けるが保証はない)。したがってtimestamp型やtimesatmptz型に対応づけるには不充分なので、DateTimeクラスを用いることにした。

timestamptz型の値は、内部的には64ビット整数 (2000-01-01 00:00:00 UTCを起点とするμ秒単位の値) として表される。タイムゾーン情報を保持しているわけではない。したがって、例えば「2010-10-10 13:00:00 +0700」というタイムスタンプを入力した場合、「2010-10-10 06:00:00 UTC」に相当する経過μ秒数が保存され、どのタイムゾーンの時刻として入力したか、という情報は失われてしまう。その後、出力の際には、システム標準のタイムゾーン (例えばJST) に変換され、「2010-10-10 15:00:00 +0900」となる (もちろんAT TIME ZONE構文で変換すればそれに従う)。一方、DateTimeクラスは、タイムゾーン情報も保持している。

ruby側の戻り値型と函数定義の戻り値型が違っている場合、(to_i、to_sなどの常識的な) 型変換が起こる。

CREATE FUNCTION int_returns_str(inp text) RETURNS integer AS
$$
	return inp.downcase;		# 戻り値はString
$$ LANGUAGE 'plrb';
SELECT	int_returns_str('-87ab');		-- -87 (integer)
SELECT	int_returns_str('NOT NUMBER');		-- 0 (integer)

複合型

テーブル名はそのまま複合型の名前として使うことができる。ruby側にはHashの形で渡される。

CREATE TABLE user_tbl (
	user_id		text	PRIMARY KEY,
	real_name	text	NOT NULL,
	privilege	integer	NOT NULL DEFAULT 0
);
CREATE FUNCTION format_user(user user_tbl) RETURNS text AS
$$
	if user['privilege'] == 0
		return "#{user['user_id']} ==> #{user['real_name']}";
	else
		return "#{user['user_id']} ::: #{user['real_name']}";
	end
$$ LANGUAGE 'plrb';

「CREATE TYPE」で定義した型も同様に使える。

CREATE TYPE color_type AS (
	name		text,
	red		integer,
	green		integer,
	blue		integer
);
CREATE FUNCTION color_value(col color_type) RETURNS integer AS
$$
	return col['red'] * 65535 + col['green'] * 256 + col['blue'];
$$ LANGUAGE 'plrb';

複合型は戻り値として使うこともできる。

CREATE FUNCTION name_to_color(color_name text) RETURNS color_type AS
$$
	## ... 省略 (色の名前からRGB値を調べる処理)
	return { 'name' => color_name, 'red' => 128, 'green' => 64, 'blue' => 64 };
$$ LANGUAGE 'plrb';

集合を返す函数 (一括モード)

戻り値型が「SETOF ...」である函数の場合、戻り値をArrayの形で返す。

CREATE FUNCTION color_table() RETURNS SETOF color_type AS
$$
	tbl = [
		{ 'name' => 'black', 'red' => 0, 'green' => 0, 'blue' => 0 },
		{ 'name' => 'gray', 'red' => 128, 'green' => 128, 'blue' => 128 },
		## ... 以下省略; ここでは定数の形で記述したが、
		## カラー・テーブルを取得する何らかの処理を記述してもよい
	]; 
	return tbl;
$$ LANGUAGE 'plrb';

Arrayの代わりに、「重複のないオブジェクトの集まり」を表すSetクラスを用いれば、関係演算との整合性が保てるかも知れない。しかし現実の関係データベースでは、レコードが重複しているテーブルも存在しうる。

戻り値をArrayの形で返す方式は、函数の戻り値型として配列を指定した場合、混乱が生じるおそれがある。しかし、配列型を導入することによる利点よりも、第1正規形が崩れるという問題の方が大きいため、広く普及するには至っていないようである。

集合を返す函数 (逐次モード)

初回の呼び出し時にArray以外を返すと、函数が繰り返し呼び出されるようになるので、集合の要素をひとつずつ返す。すべての要素を返した後、nilを返さなければならない。この点を間違えると処理が停止しないことになる。

Pg::call_counterは、何回目の呼び出しか、を表す(0、1、2、...)。次回の呼び出し時に情報を受け渡すために、Pg::user_contextを使うことができる。

CREATE FUNCTION color_table() RETURNS SETOF color_type AS
$$
	if Pg::call_counter == 0
		tbl = ## ... カラー・テーブルを取得する処理
		Pg::user_context = tbl;
	end

	tbl = Pg::user_context;
	if Pg::call_counter < tbl.size
		return tbl[Pg::call_counter];
	else
		return nil;
	end
$$ LANGUAGE 'plrb';

もちろんこの例は、一括モードで記述した方が簡単である。

Pgモジュール函数: elog出力函数

PostgreSQL側のログ機能を用いて情報を出力するよう記述できる。

CREATE FUNCTION try_elog() RETURNS text AS
$$
	if ## ....
		Pg::elog(Pg::LevelNOTICE, "Some Error Message...");
	end
$$ LANGUAGE 'plrb';

Pg::elog() の第1引数には次のいずれかを指定する。なお、「備考」欄に示した出力先は、postgresql.confの設定 (client_min_messages、log_min_messages、log_min_error_statement) により変更できる。

第1引数備考
Pg::LevelDEBUG5
Pg::LevelDEBUG4
Pg::LevelDEBUG3
Pg::LevelDEBUG2
Pg::LevelDEBUG1
Pg::LevelLOGログ・ファイルに出力
Pg::LevelCOMMERRORログ・ファイルに出力
Pg::LevelINFOコンソールに出力
Pg::LevelNOTICEコンソールに出力
Pg::LevelWARNINGコンソールとログ・ファイルに出力
Pg::LevelERROR以下は、ログ出力後、呼び出し元に制御が戻らない
Pg::LevelFATAL
Pg::LevelPANIC

トリガー

引数なしの函数として宣言するが、「opt」という引数が (暗黙に) 渡される。これを介して、イヴェントの種類など必要な情報を取り出せる。

操作前に発行される行単位のトリガーについては、nilを返す (PostgreSQL側にはNULLが返る) ことにより、当該操作を実行しないよう指示できる。また、挿入/更新される行を変更することも可能である。

CREATE TABLE code_tbl (
	code		integer,
	value		text
);
CREATE FUNCTION cant_insert_bad_code() RETURNS trigger AS
$$
	if ## opt['RECORD_NEW']['code'] に関する複雑な条件判定
		return nil;		# INSERT処理を取り消す。
	else
		return opt['RECORD_NEW'];
	end
$$ LANGUAGE 'plrb';
CREATE TRIGGER trig_bad_code_constraint BEFORE INSERT ON code_tbl
	FOR EACH ROW EXECUTE PROCEDURE cant_insert_bad_code();

こういうことは可能な限り、検査制約 (CHECK) などで実装するべきである。ここでは、検査のために、SQLの機能だけでは実現できない処理を要する状況を想定している。

optを介して渡される情報:

名前備考
WHENBEFORE | AFTER | INSTEAD
LEVELROW | STATEMENT
EVENTINSERT | UPDATE | DELETE | TRUNCATE
RECORD_OLD削除しようとする行、または更新前の行 (Hash)
RECORD_NEW挿入しようとする行、または更新後の行 (Hash)
NAMEトリガー名
TABLE_NAMEトリガーの発行元テーブル名
TABLE_SCHEMAトリガーの発行元テーブルがあるスキーマの名前
ARGCトリガー函数の引数の数
ARGVトリガー函数の引数の配列

イヴェント・トリガー

引数なしの函数として宣言するが、「opt」という引数が (暗黙に) 渡される。これを介して、イヴェントの種類など必要な情報を取り出せる。

CREATE FUNCTION monitor_event_trigger() RETURNS event_trigger AS
$$
	Pg::elog(Pg::LevelNOTICE, "[Event Trigger] TAG = #{opt['TAG']}.");
$$ LANGUAGE 'plrb';

CREATE EVENT TRIGGER monitor_ddl_command_start ON ddl_command_start
	EXECUTE PROCEDURE monitor_event_trigger();

optを介して渡される情報:

名前備考
EVENTddl_command_start | ddl_command_end | sql_drop
TAG実行対象イヴェントに関連するコマンド・タグ。"CREATE FUNCTION" など。

Pgモジュール函数: SQLコマンドの実行

函数内からSQLコマンドを実行できる。

CREATE TABLE user_tbl (
	user_id		text		PRIMARY KEY,
	passwd		text		NOT NULL DEFAULT ''
);
CREATE FUNCTION login_authorized(user_id text, passwd text) RETURNS boolean AS
$$
	sql = "SELECT count(*) AS ct FROM user_tbl WHERE user_id = $1 AND passwd = $2;";
	result = Pg::select(sql, user_id, passwd);
	## あるいは result = Pg::select_with_type(sql, Pg::TypeTEXT, user_id, Pg::TypeTEXT, passwd);
	return (result[0]['ct'] == 1);
$$ LANGUAGE 'plrb';
函数説明
Pg::execute(sql, val1, val2, ...); 「INSERT」、「UPDATE」、「DELETE」などのコマンドを実行する。
result = Pg::select(sql, val1, val2, ...); 「SELECT」コマンドを実行する。
result = Pg::funcall(func_name, val1, val2, ...); (実験的実装) PostgreSQL側に定義した函数を呼び出す。
Pg::execute_with_type(sql, type1, val1, type2, val2, ...); 「INSERT」、「UPDATE」、「DELETE」などのコマンドを実行する。偶数番目の引数はプレースホルダーの型* (下記参照)。
result = Pg::select_with_type(sql, type1, val1, type2, val2, ...); 「SELECT」コマンドを実行する。偶数番目の引数はプレースホルダーの型* (下記参照)。
plan = Pg::prepare(sql, type1, type2, ...); 実行する文を準備する。第2引数以降はプレースホルダーの型* (下記参照)。
result = Pg::exec_prepared(plan, val1, val2, ...); 準備した文を実行する。「SELECT」の場合、戻り値はPg::select() と同様。
Pg::free_plan(plan); 準備した文を解放する。
portal = Pg::open_cursor(plan, val1, val2, ...); 「SELECT」用のカーソルを設定する。
record = Pg::fetch_one(portal); 1行取り出し、カーソルを進める。
Pg::close_cursor(portal); カーソルを閉じる。

*に指定できる「型」定数:

定数(同じ意味の定数)
Pg::TypeBOOLPg::TypeBOOLEAN
Pg::TypeINT8Pg::TypeBIGINT
Pg::TypeINT2Pg::TypeSMALLINT
Pg::TypeINT4Pg::TypeINTEGER
Pg::TypeNUMERICPg::TypeDECIMAL
Pg::TypeFLOAT4Pg::TypeREAL
Pg::TypeFLOAT8Pg::TypeDOUBLE、Pg::TypeDOUBLE_PRECISION
Pg::TypeTEXT
Pg::TypeBPCHARPg::TypeCHARACTER
Pg::TypeVARCHARPg::TypeCHARACTER_VARYING
Pg::TypeBYTEA
Pg::TypeDATE
Pg::TypeTIME
Pg::TypeTIMESTAMP
Pg::TypeTIMESTAMPTZ
Pg::TypeBIT
Pg::TypeVARBITPg::TypeBIT_VARYING
Pg::TypeINTERVAL

例: ユーザー管理システム

CREATE TABLE user_tbl (
	user_id		text		PRIMARY KEY,
	passwd		text		NOT NULL DEFAULT '',
	privilege	integer		NOT NULL DEFAULT 0
);

-- パスワードは、生のままでなく暗号化して格納する。
-- レインボー・テーブル攻撃等に備えるため、ユーザーIDをソルトとして用いる。
CREATE FUNCTION encrypt_passwd(user_id text, passwd text) RETURNS text AS
$$
	require 'digest/md5';
	fixed_seed = '0ZDf7Fl3Dluuk)kjdsZ^sjDFF0S_jJDl';

	crypted_passwd = Digest::MD5::hexdigest(passwd || '');
	125.times do
		crypted_passwd = Digest::MD5::hexdigest(fixed_seed + user_id + crypted_passwd);
	end
	return crypted_passwd;
$$ LANGUAGE 'plrb';

-- ユーザーを登録しようとすると起動されるトリガー。
-- INSERTコマンドに渡されたパスワードに暗号化を施す。
CREATE FUNCTION trig_func_insert_user() RETURNS trigger AS
$$
	ret = opt['RECORD_NEW'].dup;

	# 与えられたパスワードを暗号化して格納する。
	sql = "SELECT encrypt_passwd($1::text, $2::text) AS pw;"
	crypted_passwd = Pg::select(sql, ret['user_id'], ret['passwd'])[0]['pw'];
	ret['passwd'] = crypted_passwd;

	return ret;
$$ LANGUAGE 'plrb';
CREATE TRIGGER trig_insert_user BEFORE INSERT ON user_tbl
	FOR EACH ROW EXECUTE PROCEDURE trig_func_insert_user();


-- ユーザー情報を更新しようとすると起動されるトリガー。
-- ユーザーIDをソルトとして使っているため、これを更新する場合は
-- パスワードをいったん無効にし、改めて設定する必要がある。
CREATE FUNCTION trig_func_update_user() RETURNS trigger AS
$$
	sql = "SELECT encrypt_passwd($1::text, $2::text) AS pw;"
	ret = opt['RECORD_NEW'].dup;

	if opt['RECORD_OLD']['user_id'] != opt['RECORD_NEW']['user_id']
		# user_idが変わるのでパスワードをいったん無効にする。
		ret['passwd'] = '';
	elsif opt['RECORD_OLD']['passwd'] != opt['RECORD_NEW']['passwd']
		# パスワードが更新されるか確認する。
		# そのために、与えられたパスワードを暗号化し、保存してある
		# 旧パスワードと比較する。
		user_id = opt['RECORD_NEW']['user_id'];
		passwd = opt['RECORD_NEW']['passwd'];
		crypted_passwd = Pg::select(sql, user_id, passwd)[0]['pw'];

		if opt['RECORD_OLD']['passwd'] != crypted_passwd
			# パスワードが更新される場合
			ret['passwd'] = crypted_passwd;
			Pg::elog(Pg::LevelNOTICE, "Password updated.");
		else
			ret['passwd'] = opt['RECORD_OLD']['passwd'];
		end
	end
	return ret;

$$ LANGUAGE 'plrb';
CREATE TRIGGER trig_update_user BEFORE UPDATE ON user_tbl
	FOR EACH ROW EXECUTE PROCEDURE trig_func_update_user();

-- ユーザーの認証。
-- 与えられたパスワードを暗号化し、user_tblに該当するレコードが
-- あるかどうか調べる。
CREATE FUNCTION login_authorized(user_id text, passwd text) RETURNS boolean AS
$$
	sql = "SELECT encrypt_passwd($1::text, $2::text) AS pw;"
	crypted_passwd = Pg::select(sql, user_id, passwd)[0]['pw'];

	sql = "SELECT count(*) AS ct FROM user_tbl WHERE user_id = $1 AND passwd = $2;";
	result = Pg::select(sql, user_id, crypted_passwd);
	return (result[0]['ct'] == 1);
$$ LANGUAGE 'plrb';

Pg::funcall()を使って次のように記述すれば若干見やすくなる。

CREATE FUNCTION login_authorized(user_id text, passwd text) RETURNS boolean AS
$$
	crypted_passwd = Pg::funcall('encrypt_passwd', user_id, passwd);

	sql = "SELECT count(*) AS ct FROM user_tbl WHERE user_id = $1 AND passwd = $2;";
	result = Pg::select(sql, user_id, crypted_passwd);
	return (result[0]['ct'] == 1);
$$ LANGUAGE 'plrb';

Auto-require機能

PostgreSQL函数の形でこみ入った処理を組み込もうとすると、テストのしにくさが問題になる。そこで、次のような手順で開発を進めるよう推奨する。

Auto-requireは、postgresql.confに次のように記述しておけば、自動的にrequireされる、という機能である。多くの函数にrequireを記述しなければならないような状況でも、Auto-requireならば1回で済む。

	plrb.auto_require = 'foo,baa'

複数ある場合はカンマで区切る。Rubyの$LOAD_PATH上にない場合は絶対パスで指定する。

PL/Perlには「plperl.on_init」という似た機能がある。しかし、requireに限らず何でも書ける点が、逆に問題になるおそれがある。

この考え方にもとづいて先の例を書き換えると、たとえば次のようになる。

# user_tbl_helpers.rb
require 'digest/md5';

class UserTblHelpers

	# パスワードは、生のままでなく暗号化して格納する。
	# レインボー・テーブル攻撃等に備えるため、ユーザーIDをソルトとして用いる。
	def encrypt_passwd(user_id, passwd)
		fixed_seed = '0ZDf7Fl3Dluuk)kjdsZ^sjDFF0S_jJDl';

		crypted_passwd = Digest::MD5::hexdigest(passwd || '');
		125.times do
			crypted_passwd = Digest::MD5::hexdigest(fixed_seed + user_id + crypted_passwd);
		end
		return crypted_passwd;
	end

	# ユーザーを登録しようとすると起動されるトリガー。
	# INSERTコマンドに渡されたパスワードに暗号化を施す。
	def trig_func_insert_user(opt)
		ret = opt['RECORD_NEW'].dup;

		# 与えられたパスワードを暗号化して格納する。
		crypted_passwd = encrypt_passwd(ret['user_id'], ret['passwd']);
		ret['passwd'] = crypted_passwd;

		return ret;
	end

	# ユーザー情報を更新しようとすると起動されるトリガー。
	# ユーザーIDをソルトとして使っているため、これを更新する場合は
	# パスワードをいったん無効にし、改めて設定する必要がある。
	def trig_func_update_user(opt)
		ret = opt['RECORD_NEW'].dup;

		if opt['RECORD_OLD']['user_id'] != opt['RECORD_NEW']['user_id']
			# user_idが変わるのでパスワードをいったん無効にする。
			ret['passwd'] = '';
		elsif opt['RECORD_OLD']['passwd'] != opt['RECORD_NEW']['passwd']
			# パスワードが更新されるか確認する。
			# そのために、与えられたパスワードを暗号化し、保存してある
			# 旧パスワードと比較する。
			user_id = opt['RECORD_NEW']['user_id'];
			passwd = opt['RECORD_NEW']['passwd'];
			crypted_passwd = encrypt_passwd(user_id, passwd);

			if opt['RECORD_OLD']['passwd'] != crypted_passwd
				# パスワードが更新される場合
				ret['passwd'] = crypted_passwd;
				Pg::elog(Pg::LevelNOTICE, "Password updated.");
			else
				ret['passwd'] = opt['RECORD_OLD']['passwd'];
			end
		end
		return ret;
	end

	# ユーザーの認証。
	# 与えられたパスワードを暗号化し、user_tblに該当するレコードが
	# あるかどうか調べる。
	def login_authorized(user_id, passwd)
		crypted_passwd = encrypt_passwd(user_id, passwd);

		sql = "SELECT count(*) AS ct FROM user_tbl WHERE user_id = $1 AND passwd = $2;";
		result = Pg::select(sql, user_id, crypted_passwd);
		return (result[0]['ct'] == 1);
	end
end

このファイルを /path/to/helpers/user_tbl_helpers.rb として保存したとすれば、postgresql.confに

	plrb.auto_require = '/path/to/helpers/user_tbl_helpers'

と記述する (「pg_ctl reload」などで忘れずに設定を再読み込み)。

すると、PostgreSQL側の函数は、次のようにごく簡単なものになる。

CREATE FUNCTION trig_func_insert_user() RETURNS trigger AS
$$
	helpers = UserTblHelpers.new;
	return helpers.trig_func_insert_user(opt);
$$ LANGUAGE 'plrb';
CREATE TRIGGER trig_insert_user BEFORE INSERT ON user_tbl
	FOR EACH ROW EXECUTE PROCEDURE trig_func_insert_user();

CREATE FUNCTION trig_func_update_user() RETURNS trigger AS
$$
	helpers = UserTblHelpers.new;
	return helpers.trig_func_update_user(opt);
$$ LANGUAGE 'plrb';
CREATE TRIGGER trig_update_user BEFORE UPDATE ON user_tbl
	FOR EACH ROW EXECUTE PROCEDURE trig_func_update_user();

CREATE FUNCTION login_authorized(user_id text, passwd text) RETURNS boolean AS
$$
	helpers = UserTblHelpers.new;
	return helpers.login_authorized(user_id, passwd);
$$ LANGUAGE 'plrb';

ここではあえて極端に走り、実質的な実装をすべてuser_tbl_helpers.rbに分離してしまう、という書き方を示した。実際には処理内容に応じて、どこで分離するか判断するべきである。

テスト用のヘルパー・モジュール

たとえばlogin_authorized()の動作をテストする場合、「SELECT login_authorized('Sally', 'pass_brown');」のようなSQLを実行し、返されたレコードが想定どおりであるかどうか確認することになるであろう。しかし、上記のように実質的な実装コードを分離してしまえば、ruby側のlogin_authorized()にいろいろな引数値を渡して戻り値を確認しても、ほぼ目的を達成できる。たとえば次のようなテスト・プログラムになるであろう。

	test_cases = [
		[ 'Sally', 'pass_brown', true ],
		[ 'Sally', 'BAD_PASSWD', false ],
	];
	helpers = UserTblHelpers.new;
	test_cases.each do |user_id, passwd, guess_result|
		result = helpers.login_authorized(user_id, passwd);
		am_assert_equals(guess_result, result);
	end

しかし、login_authorized()の実装にはPg::select()が使われているので、このままでは動作しない。

そこでpgx.rbに、テストの際に用いるPg::select()その他の代替実装を用意した。

Pg::execute(sql, val1, val2, ...); PostgreSQLのクライアント・インターフェイスであるlibpqを使って実装しているが、ほぼ同じように動作する。
result = Pg::select(sql, val1, val2, ...);
Pg::execute_with_type(sql, type1, val1, type2, val2, ...); 未実装。
result = Pg::select_with_type(sql, type1, val1, type2, val2, ...);
plan = Pg::prepare(sql, type1, type2, ...);
result = Pg::exec_prepared(plan, val1, val2, ...);
Pg::free_plan(plan);
portal = Pg::open_cursor(plan, val1, val2, ...);
record = Pg::fetch_one(portal);
Pg::close_cursor(portal);
result = Pg::funcall(func_name, val1, val2, ...); なし (無意味)
Pg::connect(conninfo) データベースへの接続と切断* (下記参照)。
Pg::disconnect()
Pg::elog(level, message) levelにかかわらず、標準エラー出力にメッセージを出力する。

(*) テストに先立ってデータベースに接続する必要がある。conninfoには、たとえば 'postgresql://local_host/db_plrb' のような文字列を指定する。

このpgx.rbを使ったテスト・プログラム例を以下に示す。

require_relative 'user_tbl_helpers';
require_relative 'pgx';
require_relative 'am_assert';
include AmAssert;

# ======================================================================
#
class TestUserTblHelpers

	def setup()
		Pg::connect('postgresql://localhost/db_plrb');
			# データベースへの接続: この節で説明した方式で
			# 実装し、テストする場合にのみ必要。
	end

	def teardown()
		Pg::disconnect();
	end

	def test_login_authorized()
		setup();

		# テスト環境の準備: ユーザーを登録する。
		@pq = Pq.new('postgresql://localhost/db_plrb');
		@pq.execute("DELETE FROM user_tbl;");
		@pq.execute("INSERT INTO user_tbl (user_id, passwd) VALUES ($1, $2);",
				'Sally', 'pass_brown');

		# ----------------------------------------------------
		test_cases = [
			[ 'Sally', 'pass_brown', true ],
			[ 'Sally', 'BAD_PASSWD', false ],
		];
		helpers = UserTblHelpers.new;
		test_cases.each do |user_id, passwd, guess_result|
			result = helpers.login_authorized(user_id, passwd);
					# login_authorized()の実装には
					# Pg::select()の記述があるが、
					# テストの際には代替実装版の
					# Pg::select()を実行する。
			am_assert_equals(guess_result, result);
		end

		teardown();
	end
end

# ======================================================================
#
if __FILE__ == $0
	t = TestUserTblHelpers.new;
	t.test_login_authorized();
end

履歴


Copyright © 2015 KOYAMA Hiro <tac@amris.co.jp>