ActiveRecord (1) -- construct_sql
2008/07/20
次に掲載するのは、lib/active_record/associations
ディレクトリにある has_many_association.rb
からの抜粋です。ただし、ソースコード全体がこのページの表示幅に収まるように、少し変更してあります。なお、ご紹介するソースコードはすべて ActiveRecord 2.1.0 のものです。
module ActiveRecord module Associations class HasManyAssociation < AssociationCollection #:nodoc: # (省略) protected def construct_sql case when @reflection.options[:finder_sql] @finder_sql = interpolate_sql(@reflection.options[:finder_sql]) when @reflection.options[:as] @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = " + "#{@owner.quoted_id} AND " + "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = " + "#{@owner.class.quote_value(@owner.class.base_class.name.to_s)}" @finder_sql << " AND (#{conditions})" if conditions else @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = " + "#{@owner.quoted_id}" @finder_sql << " AND (#{conditions})" if conditions end if @reflection.options[:counter_sql] @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) elsif @reflection.options[:finder_sql] # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */ @reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub( /SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" } @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) else @counter_sql = @finder_sql end end # (省略) end end end
construct_sql
というメソッドを定義しています。HasManyAssociationクラスのインスタンスが生成されるときに、このメソッドが呼ばれます。
このメソッドの目的は、2つのインスタンス変数 @finder_sql
と @counter_sql
に SQL 文の断片を格納することです。これらの SQL 文の断片は find
や count
メソッドの中で利用されることになります。
まずは冒頭部分を見てください。
module ActiveRecord module Associations class HasManyAssociation < AssociationCollection #:nodoc:
ActiveRecord::Associations モジュールの下に HasManyAssociation クラスを作っています。
Ruby 言語における「モジュール」の最も重要な役割は、名前空間を提供することです。クラス名が衝突しないようにすると同時に、読む者にクラスがどのような種類のものであるかを探るヒントを与えてくれます。
HasManyAssociation クラスは AssociationCollection クラスを継承しています。Ruby 言語では &;
がクラスとクラスの親子関係を表します。
その右にある #:nodoc
は、RDoc の修飾子(modifier)の一種です。RDoc は、Ruby ソースコードからドキュメントを生成するプログラムです。RDcoc はこの修飾子が添えられたクラスやメソッドをドキュメント生成の対象から外します。
さて、HasManyAssociation の親クラス AssociationCollection は、さらに AssociationProxy クラスを継承しています。このクラスの役割は何でしょうか。
次の例をご覧ください。
class Club < ActiveRecord::Base has_many :members end club = Club.find(:first) members = club.members
変数 members に格納されるのが AssociationProxy オブジェクトです。
このオブジェクトは一見すると Array オブジェクトのように見えます。ためしに、members.class.name
を評価してみると、Array という文字列を返します。しかし、だまされてはいけません。
次のソースコードを見てください。
class AssociationProxy #:nodoc: alias_method :proxy_respond_to?, :respond_to? alias_method :proxy_extend, :extend delegate :to_param, :to => :proxy_target instance_methods.each { |m| undef_method m unless m =~ /(^__|^nil\?$|^send$|proxy_|^object_id$)/ } # (中略) private def method_missing(method, *args) if load_target if block_given? @target.send(method, *args) { |*block_args| yield(*block_args) } else @target.send(method, *args) end end end
AssociationProxy は Object クラスから継承したメソッドのうち、名前が正規表現にマッチしないものをすべて undef_method で消してしまっています(5行目)。当然、class メソッドも消えてしまいます。ということは、私たちが class メソッドを呼ぶと、method_missing
メソッドに拾われて、@target
の class メソッドが呼ばれます。@target
の中身は配列なので、結局は Array という文字列が返ってくるというわけです。
ここで注目したいのは、method_missing
メソッドの中で load_target
メソッドが呼ばれている、という事実です。このメソッドが起点になって、実際に SQL 文がデータベース管理システムに対して発行され、@target
に値が格納されます。
私たちは club.members.find(:all, :order => 'created_at')
のような書き方をしますが、club.members
まで評価した時点で SQL が発行されてしまうと、無駄なデータベースアクセスが発生してしまいます。それを防止しているわけです。
本題のソースコードに戻りましょう。
protected def construct_sql
protected
は、Ruby 言語の特別なキーワードのように見えますが、Module クラスのインスタンスメソッドです。
それ以降に定義されるメソッドの可視性を protected にします。
Ruby 言語には3種類の可視性 (public, protected, public) があって、Java 言語と用語が同じですが、意味はかなり違います。
Java の場合、private メソッドは同じクラスからしか呼べません。protected にするとそのサブクラス及び同じパッケージに属するクラスから呼べるようになります。
他方、Ruby の場合、private メソッドでも protected メソッドでも、同じクラス及びサブクラスからしか呼べません。ただし、private メソッドはレシーバー形式(オブジェクトの後ろにドットとメソッド名を付ける書き方)で呼ぶことができません。
実のところ、Ruby 言語の protected を使わなければならない場面というのはあまり多くない(private で代用できる)のですが、Ruby on Rails のソースコードではかなり多用されています。筆者の調べた限りでは、construct_sql
メソッドは private でも問題ないようです。protected にした理由はよくわかりません。ほぼ同じことをしている HasOneAssociation クラスの construct_sql
メソッドは private にしてあるので、単なる間違いでしょう。
ところで、ActiveRecord のコーディングスタイルでちょっと興味深いのは、protected の宣言以降のインデントを1段(つまり、空白2文字分)下げていることです。
メソッドが public でないことを明示するための措置と思われますが、クラス定義の末尾でインデントがずれているように見えるので、筆者自身は少し気持ち悪いと感じています。
しかし、Ruby on Rails の他のコンポーネント(ActionController 等)のソースコードでも一貫してこのルールが守られていますので、Rails の開発者たちの規約として確立しているようですね。
次に進みましょう。
case when @reflection.options[:finder_sql] @finder_sql = interpolate_sql(@reflection.options[:finder_sql]) when @reflection.options[:as] @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = " + "#{@owner.quoted_id} AND " + "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = " + "#{@owner.class.quote_value(@owner.class.base_class.name.to_s)}" @finder_sql << " AND (#{conditions})" if conditions else @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = " + "#{@owner.quoted_id}" @finder_sql << " AND (#{conditions})" if conditions end
通常、インスタンス変数 @reflection
には、ActiveRecord::Reflection::AssociationReflection オブジェクトが格納されています。
reflection という英単語は、一般的な用法としては「(光や音の)反射」とか「内省」という意味がありますが、Rails 用語では「メタデータ」に近い意味で使われます。つまり、あるモデルと別のモデルの間の関連(association)に関する情報を保持するためのオブジェクトです。例えば、この関連が結びつけている両モデルのクラス名とか、has_many
メソッドに与えたオプションとかが @reflection
に格納されているいるのです。
さて、上記のソースコードの断片では case
式が用いられています。
Ruby 言語の case
式は、if
式の代用品としての役割と、C 言語や Java 言語の switch
文に近い役割の2つを持っていますが、ここでは前者です。
if
を使って書き直せば、次のようになります。
if @reflection.options[:finder_sql] @finder_sql = interpolate_sql(@reflection.options[:finder_sql]) elsif @reflection.options[:as] @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = " + "#{@owner.quoted_id} AND " + "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = " + "#{@owner.class.quote_value(@owner.class.base_class.name.to_s)}" @finder_sql << " AND (#{conditions})" if conditions else @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = " + "#{@owner.quoted_id}" @finder_sql << " AND (#{conditions})" if conditions end
ActiveRecord の作者が case
を使った理由は分かりません。
@reflection.options
には、has_many
メソッドに与えたオプションが格納されています。:finder_sql
オプションは、テーブル間の関連づけのための SQL コードを指定するためのものです。そのまま @finder_sql
に格納してしまえばよさそうなものですが、interpolate_sql
メソッドを通しています。
このメソッドは AssociationProxy のインスタンスメソッドです。
def interpolate_sql(sql, record = nil) @owner.send(:interpolate_sql, sql, record) end
ここで、@owner
は ActiveRecord::Base オブジェクトです。そちらの定義は次の通り。
def interpolate_sql(sql, record = nil) instance_eval("%@#{sql.gsub('@', '\@')}@") end
メソッド名で使われている interpolate はあまり聞き慣れない英単語です。英和辞典によれば「(文章を)改ざんする」という意味だそうですが、Ruby 用語としては、文字列の中に #{ ... } 記法で式を埋め込むことを指します。
interpolate_sql
メソッドの引数 sql
に次のような文字列が格納されていたとします。
members.club_id = #{id}
すると、instance_eval
メソッドには次のような文字列が渡ることになります。
%@members.club_id = #{id}@
instance_eval
メソッドは、これを Ruby コードとして、このインスタンスの文脈で評価して返します。%@ ... @
は文字列の始まりと終わりを示しています。%Q{ ... }
と同じです。
つまり、
members.club_id = 123
というような文字列になります。ただし、123 はこのインスタンスの id です。
次の when
節では has_many
メソッドに :as
オプションが指定された場合の処理をしています。このオプションは polymorphic associations を指定するためのものですが、筆者自身がこのオプションを使ったことがありませんので、説明は省くことにしましょう(^^;)。
最後の else
節の中身は、次のようになっています。
@finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = " + "#{@owner.quoted_id}" @finder_sql << " AND (#{conditions})" if conditions
@reflection.quoted_table_name
は、引用符で囲まれた関連先テーブルの名前を返します。
前出のモデル Club を例に取れば、関連先テーブルは members です。テーブル名を囲む引用符はデータベース管理システムによって異なります。MySQL の場合はバッククオート(`)ですので、`members` になります。
@reflection.primary_key_name
は、members テーブルから clubs テーブルへの外部キーの名前を返します。Rails の規約に従ってテーブルが設計されているなら、club_id になります。
@owner.quoted_id
は、関連元のレコードの主キーの値です。"quoted" とありますが、主キーの値は Fixnum なのでそのまま to_s で文字列に変換されるだけです。
まとめると、@finder_sql
には `members`.user_id = 123 のような文字列が格納されることになります。そして、conditions
が nil でなければ、AND で結んで @finder_sql
の末尾に追加します。
ここまで理解できれば、残りはそんなに難しくありません。
if @reflection.options[:counter_sql] @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) elsif @reflection.options[:finder_sql] # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */ @reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub( /SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" } @counter_sql = interpolate_sql(@reflection.options[:counter_sql]) else @counter_sql = @finder_sql end
ちょっとややこしいのは、sub
メソッドで使われている正規表現ぐらいですね。
/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im
(\/\*.*?\*\/ )?
の部分は、SQL 文の中に埋め込まれたコメントです。コメントを加えた SQL 文を :finder_sql オプションに指定する人がたくさんいるとは思えませんが、わざわざコメントを保存しているところから考えると、コメントによって挙動が変わるデータベース管理システムが存在するのかもしれません(どなたか知っていたら教えてください!)。
\b
は、ワード文字列と非ワードの境界(boundary)にマッチします。ワード文字列とは、文字列クラス [A-Za-z9-0_] のことです。
末尾に付けられた im
は、正規表現オプションで、「大文字と小文字を区別しない(i)」、「ドット(.)を改行文字にもマッチさせる(m)」という意味になります。
--
黒田努