先日開催されたMF Rails勉強会 ~Rails 1.0のコードを読む~ - connpassに参加してきた。
その感想と、読みきれなかった部分の続きをば。
勉強会について
- 短い時間でも読めるように、まだ小さかったRails 1.0
- テーマごとにグループに分かれて読み進め最後に軽く発表
という形だった。
ちなみに僕はActiveRecordのfindを読むグループに参加した。
今後について
- 今度は2.0で?
- Ruby自体のコードを読む勉強会も?
とういうような話も出ていたので期待。
普段人がどうやってコードを読んでいるかを見る機会というのは無いし、コードをどうやって読むかという情報もあまり共有されておらず暗黙知になっている。グループでコードを読むというのはこの暗黙知が少し共有されるという意味でも貴重な体験で、もっとやってみたいなぁと思った。
非常に楽しかったです。
企画運営をしてくださった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は今と結構違って、
Person.find(7, **options)
Person.find(:first, **options)
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
の場合。
:first
の場合にはincludeされてなければoptionsに{limit: 1}
をmergeして:all
に- id指定の場合にはconditionsに
"#{table_name}.#{primary_key} = #{id}"
を追加して:all
に
飛ばされる。では:all
の場合は何をしているかというと
:include
オプション付きならfind_with_associations(options)
:include
オプション無しならfind_by_sql(construct_finder_sql(options))
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
scope
はallow_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_attribute
はactivesupport/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オプションが付いている場合は注意が必要で、
belongs_to
,has_one
だけ使っている(using_limitable_reflections?(reflections) == true
)場合にはJOIN後にLIMITしても意図通りなのでそうする- それ以外の場合には、
add_limited_ids_condition!(sql, options)
で、大本のテーブル単独で条件を満たすレコードのidを事前に引いて、それをJOINするSQLの条件に加える
ようになってる。
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
残りはとってきたレコードを頑張って入れるという感じなのでいいのではないでしょうか。
読んでみた感想
- APIがシンプル。
- 変な命名をしているところはないし、メタプログラミングっぽいことをしている部分以外は大体読みやすい。
- 特に抽象化やコードのきれいさという観点ですごく優れているというわけでは無い。
自分も
- ユーザにとって重要な問題を解決している
- 使っていて気持ちいい
ものを作りたいものです。