l'essentiel est invisible pour les yeux

Friday, February 17, 2006

[ActiveRecord Hacks] Dynamic Finder

ActiveRecord Hacks - Dynamic Finder

ActiveRecord Hacksでは、ActiveRecordの面白いメソッドを紹介しながら、どうやって実装しているのかをソースコードを見ながら検証してみます。

ActiveRecordにはDyanamiFinderという機能が実装されています。
(静的言語を主に使ってる人にとっては、これはもうマジックすね。)

Dyanamic Finderは、読んで文字のごとく動的なレコード検索機能を提供します。例えば、こんなスキーマがあったとします。

CREATE NOT IF EXIST TABLE drecoms(
id INT AUTO_INCREMENT,
name VARCHAR(50),
email VARCHAR(100),
);

例えば、「rakuto」という名前の社員を検索したいときに、self.findメソッドを使いますよね。

rakuto = Drecom.find(:all, :conditions => ["name = ?", "rakuto"])

Dynamic Finderの機能を使えば、次のように書くことが出来ます。

rakuto = Drecom.find_all_by_name("rakuto")

hoge@hoge.comというemailを持っている社員は次のように検索できます。
hoge = Drecom.find_by_email("hoge@hoge.com")

rakutoという名前で、hoge@hoge.comというメールアドレスを持つ社員ならば、
rakuto = Drecom.find_by_name_and_email('rakuto', 'hoge@hoge.com')


ヤバイっすねこの機能。
どういう仕組みで動作するのでしょう?もちろんfind_by_(カラム名)というメソッドがActiveRecord::Base内に実装されているわけではないことは一目瞭然です。
動的にカラムを条件として参照するためのメソッドを作り出しています。

このマジックの種明かしをするために、ActiveRecord::Baseのソースを追ってみます。

Drecom.find_by_(カラム名)というメソッドを呼び出したときに、宣言されていないので静的言語ならコンパイルエラーとかでアボーンですが、Rubyではmethod_missingメソッドが呼ばれます。

activerecord-1.13.2で言えば、970行目の"def method_missin(method_id, *arguments)"になります。

def method_missing(method_id, *arguments)
if match = /find_(all_by|by)_([_a-zA-Z]\w*)/.match(method_id.to_s)
finder = determine_finder(match)

attribute_names = extract_attribute_names_from_match(match)

super unless all_attributes_exists?(attribute_names)
conditions = construct_conditions_from_arguments(attribute_names, arguments)

コールされたメソッドが存在しない場合、メソッド名が"find_(all_by or by)_(アルファベット)"にマッチするかを調べます。determine_finderは、一致するカラム全て検索(find_all_by)なのか、一つだけ取り出すのか(find_by)を特定します。

次にextract_attribute_names_from_matchメソッドでメソッド名を"_and_"でsplitしてカラム名を配列として取り出し、メソッド名として指定されたカラムが全て存在するかどうかを調べています。

そして、"extract_attribute_names_from_match(match)"メソッドで"name = 'rakuto' AND email = 'hoge@hoge.com'"といったSQLの条件部分を作成します。

メソッドの振る舞いの実体は出来上がりました。残るはメソッドにリミットやオフセットなどのオプションの処理とベースのfindメソッドの呼び出しです。

if arguments[attribute_names.length].is_a?(Hash)
find(finder, { :conditions => conditions }.update(arguments[attribute_names.length]))
else
send("find_#{ finder}", conditions, *arguments[attribute_names.length..-1]) # deprecated API
end

もし、Drecom.find_by_name('rakuto', :limit => 10)といった感じでオプションが与えられている場合とそうでない場合で呼び出す基本のfindメソッドを切り替えています。

条件部分を作り出しているメソッドをもう少し詳しく見たいと思います。

def construct_conditions_from_arguments(attribute_names, arguments)
conditions = []
attribute_names.each_with_index { |name, idx| conditions << "#{table_\ name}.#{ connection.quote_column_name(name)} #{attribute_condition(arguments[idx\ ])} " } [ conditions.join(" AND "), *arguments[0...attribute_names.length] ] end

引数としてカラム名の配列と条件として指定した値('rakuto')を取ります。

それらをconditons配列にPUSHしてSQLインジェクション防止のためにプリペアドステートメントを適用します。返り値は、['name = ? AND emai = ?', ['rakuto', 'hoge@hoge.com']]といった感じです。


まとめ

強力で面白い機能ですが、インデクスの張り具合を理解していない厨房が使うと死にます。インデクスの張られていないカラムを検索条件としてばんばん検索をかけるはめに。

Tag: