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 文の断片は findcount メソッドの中で利用されることになります。

まずは冒頭部分を見てください。

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)」という意味になります。

--
黒田努