Rails 1.0のActiveRecord::Base.findを読む

先日開催されたMF Rails勉強会 ~Rails 1.0のコードを読む~ - connpassに参加してきた。
その感想と、読みきれなかった部分の続きをば。

勉強会について

という形だった。
ちなみに僕はActiveRecordのfindを読むグループに参加した。

今後について

とういうような話も出ていたので期待。

普段人がどうやってコードを読んでいるかを見る機会というのは無いし、コードをどうやって読むかという情報もあまり共有されておらず暗黙知になっている。グループでコードを読むというのはこの暗黙知が少し共有されるという意味でも貴重な体験で、もっとやってみたいなぁと思った。

非常に楽しかったです。
企画運営をしてくださったMoney Forwardさん、松田さんありがとうございました。

個人的な反省点としては複数人でコードを読むというのが初めてで、どういう風に進めていけばいいのだろうと思っている間に終わってしまった感があったところ。皆手探りの状態の中リーダーシップの無い行動しかできなかったところ。

ActiveRecord::Base.findを読む

下準備

$ ghq get rails/rails
$ git checkout v1.0.0

勉強会ではコメントをそのまま読んでいたけど、
RDocでドキュメントを生成してみてもよかったのかもしれない。

実際手元でやるとしたら

$ ghq look rails/rails
# obsoleteなものをrequireしていたりするのでpatch
$ curl https://gist.githubusercontent.com/en30/d4fff101aec19c546da6b0b415c6cde6/raw/26c845254a3649b84c101ea09b5a8277ec14cc16/gistfile1.txt | patch -p1
$ cd activerecord
$ rake rdoc
$ open doc/ActiveRecord/Base.html

という感じ。

ドキュメントを見てみる

findは今と結構違って、

  1. Person.find(7, **options)
  2. Person.find(:first, **options)
  3. Person.find(:all, **options)

と使う。

ActiveRecord::Relationは無く、条件は

Person.find(1, conditions: ['user_name = ?', username], order: 'created_on DESC')

とHashで渡す。シンプル。

ActiveRecord::Base.find

いきなり40行近くある。idが配列で渡された場合の処理とかもあるのだけどすごく単純化すると

def find(*args)
  options = extract_options_from_args!(args)
  case args.first
    when :first
      find(:all, options.merge(options[:include] ? { } : { :limit => 1 })).first
    when :all
      records = options[:include] ? find_with_associations(options) : find_by_sql(construct_finder_sql(options))
      records.each { |record| record.readonly! } if options[:readonly]
      records
    else
      conditions = " AND (#{sanitize_sql(options[:conditions])})" if options[:conditions]
      if result = find(:first, options.merge({ :conditions => "#{table_name}.#{primary_key} = #{sanitize(ids.first)}#{conditions}" }))
          return result
      else
        raise RecordNotFound, "Couldn't find #{name} with ID=#{ids.first}#{conditions}"
      end
    end
  end
end

基本となっているのは第一引数が:allの場合。

飛ばされる。では:allの場合は何をしているかというと

find_by_sql(construct_finder_sql(options))

先に軽そうな:includeオプションなしの場合を見る。

def find_by_sql(sql)
  connection.select_all(sanitize_sql(sql), "#{name} Load").collect! { |record| instantiate(record) }
end

sanitize_sqlは名前より働き者でプレースホルダにサニタイズした値を入れたりもしている。
instantiateというのが気になるが、これはSTIを使っている場合にも適切なクラスでインスタンス化するための処理をしている。ちなみにその中でもnewではなくallocateを使っていて、initializeを通るかどうかでnew_record?かどうかを区別している様子。

ちなみに勉強会では「1.0でもSTIがあるんですね〜」という反応があった。PoEAAの影響っぽそう。

construct_finder_sqlは割りとそのままで、

def construct_finder_sql(options)
  sql  = "SELECT #{options[:select] || '*'} FROM #{table_name} "
  add_joins!(sql, options)
  add_conditions!(sql, options[:conditions])
  sql << " GROUP BY #{options[:group]} " if options[:group]
  sql << " ORDER BY #{options[:order]} " if options[:order]
  add_limit!(sql, options)
  sql
end

大体予想通りのものがあるだろうなと思ってadd_joins!を見に行くと

def add_joins!(sql, options)
  join = scope(:find, :joins) || options[:joins]
  sql << " #{join} " if join
end

scopeなるものが出てきて面食らう。
見落としていたがscopeをつくるActiveRecord::Base.with_scopeがあった。
使い方は

Article.with_scope(:find => { :conditions => "blog_id = 1" }, :create => { :blog_id => 1 }) do
  Article.find(1) # => SELECT * from articles WHERE blog_id = 1 AND id = 1
  a = Article.create(1)
  a.blog_id == 1
end

scopeallow_concurrencyされているならThread-local変数で、そうでないならインスタンス変数でHashとして持っている。keyは:find:createのみ。

それさえわかればadd_conditions!add_limit!も基本的にそのままなのでconstruct_finder_sqlはここまで。

find_with_associations(options)

今度はfindにincludeオプションが渡されている場合。

長いので少しずつ

module ActiveRecord
  module Associations
    def find_with_associations(options = {})
      reflections  = reflect_on_included_associations(options[:include])
      #...
    end
  end
end
def reflect_on_included_associations(associations)
  [ associations ].flatten.collect { |association| reflect_on_association(association.to_s.intern) }
end
def reflect_on_association(association)
  reflect_on_all_associations.find { |reflection| reflection.name == association }
end
def reflect_on_all_associations
  read_inheritable_attribute(:associations) or write_inheritable_attribute(:associations, [])
end

read_inheriable_attributeactivesupport/lib/active_support/class_inheritable_attributes.rbで定義されている。クラスインスタンス変数@inheritable_attributesとそのaccessorが色々、あと継承時にコピーするようにClass#inheritedにalias_method_chain的なことをしている。

さて@inheritable_attributes[:associations]はどこで定義されているのだろう?
とりあえずと思って見に行ったActiveRecord::Associations::ClassMethods#belongs_toには見つからずしばらく彷徨っていると

module ActiveRecord
  module Reflection # :nodoc:
    def self.append_features(base)
      # ...
      for association_type in %w( belongs_to has_one has_many has_and_belongs_to_many )
        base.module_eval <<-"end_eval"
          class << self
            alias_method :#{association_type}_without_reflection, :#{association_type}

            def #{association_type}_with_reflection(association_id, options = {}, &block)
              #{association_type}_without_reflection(association_id, options, &block)
              reflect_on_all_associations << AssociationReflection.new(:#{association_type}, association_id, options, self)
            end

            alias_method :#{association_type}, :#{association_type}_with_reflection
          end
        end_eval
      end
    end
  end
end

が見つかった。reflect_on_all_associations <<だったのでみつかりにくかった…
append_featuresはモジュールがインクルードされる前に呼ばれるので、モデルのクラスにアソシエーションを定義した時にちゃんと@inheritable_attributes[:associations]に書き込まれていそうだ。これで安心して先に進める。

def find_with_associations(options = {})
  # ...
  schema_abbreviations = generate_schema_abbreviations(reflections)
  primary_key_table    = generate_primary_key_table(reflections, schema_abbreviations)
  # ...
end

generate_schema_abbreviationsは名前通り関連先テーブルも含めて、テーブル名とカラム名にそのabbrevのマッピングを持つHashを作る。"t#{i}_r#{j}" => [table, column]みたいな。
generate_primary_key_tableは関連先テーブルも含めテーブル名 => プライマリーキーのabbrevをもつHashを作る。

def find_with_associations(options = {})
  # ...
  rows = select_all_rows(options, schema_abbreviations, reflections)
  # ...
end
def select_all_rows(options, schema_abbreviations, reflections)
  connection.select_all(
    construct_finder_sql_with_included_associations(options, schema_abbreviations, reflections),
    "#{name} Load Including Associations"
  )
end
def construct_finder_sql_with_included_associations(options, schema_abbreviations, reflections)
  sql = "SELECT #{column_aliases(schema_abbreviations)} FROM #{table_name} "
  sql << reflections.collect { |reflection| association_join(reflection) }.to_s
  sql << "#{options[:joins]} " if options[:joins]

  add_conditions!(sql, options[:conditions])
  add_sti_conditions!(sql, reflections)
  add_limited_ids_condition!(sql, options) if !using_limitable_reflections?(reflections) && options[:limit]

  sql << "ORDER BY #{options[:order]} " if options[:order]

  add_limit!(sql, options) if using_limitable_reflections?(reflections)

  return sanitize_sql(sql)
end

基本的には見たとおりの感じになってる。

limitオプションが付いている場合は注意が必要で、

ようになってる。

def find_with_associations(options = {})
  # ...
  records, records_in_order = { }, []
  primary_key               = primary_key_table[table_name]

  for row in rows
    # ...
  end
  records_in_order
end

残りはとってきたレコードを頑張って入れるという感じなのでいいのではないでしょうか。

読んでみた感想

自分も

ものを作りたいものです。