ActiveRecordについて調べる必要があったのでまとめました。
調べたソースコードは activerecord-2.3.5 です。
どっか間違えていたら教えてもらえると助かります。><
ActiveRecord::Base#findから
ActiveRecordから派生したクラスを使い、下記のようなコードで呼び出す。
Person.find(:all, :conditions => [ "category IN (?)", categories], :limit => 50) Person.find(:all, :conditions => { :friends => ["Bob", "Steve", "Fred"] }
findのコードはどうなっているかというと、
def find(*args) options = args.extract_options! validate_find_options(options) set_readonly_option!(options) case args.first when :first then find_initial(options) when :last then find_last(options) when :all then find_every(options) else find_from_ids(args, options) end end
optionsに上記で渡した、conditionsやlimitが入ります。
最初の引数により、find_initial, find_last, find_everyを呼び出すようになっている。
想定したシンボルじゃないものが渡された時は、IDが渡されたと判断して、find_from_ids を呼びます。
ActiveRecord::Base#find_initial/find_last
find -> find_initial
find_initial/find_lastは最後にfind_everyを呼び出しているので、先に説明します。
def find_initial(options) options.update(:limit => 1) find_every(options).first end
find_initialはlimit=1を設定して、find_everyを呼び出しています。
def find_last(options) order = options[:order] if order order = reverse_sql_order(order) elsif !scoped?(:find, :order) order = "#{table_name}.#{primary_key} DESC" end if scoped?(:find, :order) scope = scope(:find) original_scoped_order = scope[:order] scope[:order] = reverse_sql_order(original_scoped_order) end begin find_initial(options.merge({ :order => order })) ensure scope[:order] = original_scoped_order if original_scoped_order end end
find_lastはorderが指定されていればそのorderの項目、なければprimary keyを逆順にしてfind_initialを呼んでいます。
ActiveRecord::Base#find_every
find -> find_initial -> find_every
def find_every(options) include_associations = merge_includes(scope(:find, :include), options[:include]) if include_associations.any? && references_eager_loaded_tables?(options) records = find_with_associations(options) else records = find_by_sql(construct_finder_sql(options)) if include_associations.any? preload_associations(records, include_associations) end end records.each { |record| record.readonly! } if options[:readonly] records end
find_everyはassociationを使ってJOINしたクエリを投げてrecordsを取得したり(find_with_associations)、
option(conditionやlimitなど)を渡して、recordsを取得します。(find_by_sql)
ActiveRecord::Base#construct_finder_sql
find -> find_initial -> find_every -> construct_finder_sql
find_by_sqlに渡す前にconstruct_finder_sqlを使って、optionsからクエリを構築します。
def construct_finder_sql(options) scope = scope(:find) sql = "SELECT #{options[:select] || (scope && scope[:select]) || default_select(options[:joins] || (scope && scope[:joins]))} " sql << "FROM #{options[:from] || (scope && scope[:from]) || quoted_table_name} " add_joins!(sql, options[:joins], scope) add_conditions!(sql, options[:conditions], scope) add_group!(sql, options[:group], options[:having], scope) add_order!(sql, options[:order], scope) add_limit!(sql, options, scope) add_lock!(sql, options, scope) sql end
SELECT #{options[:select] ||.... の部分は、
- findメソッドを呼んだ時の:selectオプションで指定したものがある場合、それをカラム名として使用する
- scope[:select]は使っていないのかな?(あとで調べる)
- どちらもなければ、default_selectを通じて、joinしたテーブルがあれば、quoted_table_name.* / なければ * を返す
FROM #{options[:from] || ...の部分は、
- options[:from] findのoptionsに:fromがあれば、それをテーブル名として使用する
- scope[:from]は使っていないのかな?(あとで調べる)
- どちらもなければ、quoted_table_nameをを返す
さて、ココでquoted_table_nameって何?と思ったのでソースを見てみます。
find -> find_initial -> find_every -> construct_finder_sql -> quoted_table_name
def self.quoted_table_name self.connection.quote_table_name(self.table_name) end
connectionのquote_table_nameに自分のtable_nameを渡して、quoteしているようですね。
connectionは接続先によって変わるので、覚えておいて後で詳しく見てみましょう。
add_joins!はSQL Injectionにあまり関係がなさそうなので、スルー。
SQL Injectionの場合に注意したいのが、add_conditions!です。
よく、select * from table_name where xxx = '[value]' の [value] を [' and 1 = 1--]とかにされて、
select * from table_name where xxx= '' and 1 = 1--' というクエリを投げてしまうコードを書いてしまうことがあります。
ActiveRecord::Base#add_conditions!
find -> find_initial -> find_every -> construct_finder_sql -> add_conditions!
def add_conditions!(sql, conditions, scope = :auto) scope = scope(:find) if :auto == scope conditions = [conditions] conditions << scope[:conditions] if scope conditions << type_condition if finder_needs_type_condition? merged_conditions = merge_conditions(*conditions) sql << "WHERE #{merged_conditions} " unless merged_conditions.blank? end
気がついたけど、scopeはnamed_scopeのことかな?(後で聞いてみる)
- finder_needs_type_conditions?とtype_condition
ActiveRecord::Baseを派生した時に使用するため、割愛。*1
ActiveRecord::Base#merge_conditions
find() -> .... -> construct_finder_sql -> add_conditions! -> merge_conditions
def merge_conditions(*conditions) segments = [] conditions.each do |condition| unless condition.blank? sql = sanitize_sql(condition) segments << sql unless sql.blank? end end "(#{segments.join(') AND (')})" unless segments.empty? end
- conditionsのArrayをそれぞれの項目に対して、sanitize_sqlをする
- 結果を (xxx) AND (yyy) のような文字列にして返す。
ふー、次はようやくsanitize_sqlだ。
ActiveRecord::Base#sanitize_sql
find() -> .... -> add_conditions! -> merge_conditions -> sanitize_sql
alias_method :sanitize_sql, :sanitize_sql_for_conditions
お、よく見ると、sanitize_sqlはsanitize_sql_for_conditionsのエイリアスじゃないか。
なので、sanitize_sql_for_conditionsを見よう。
ActiveRecord::Base#sanitize_sql_for_conditions
find() -> .... -> add_conditions! -> merge_conditions -> sanitize_sql_for_conditions
def sanitize_sql_for_conditions(condition, table_name = quoted_table_name) return nil if condition.blank? case condition when Array; sanitize_sql_array(condition) when Hash; sanitize_sql_hash_for_conditions(condition, table_name) else condition end end
なるほど、conditionがArrayの時は、sanitize_sql_array, Hashの時はsanitize_sql_hash_for_conditionsを呼ぶのか。
ActiveRecord::Base#sanitize_sql_array
find() -> .... -> merge_conditions -> sanitize_sql_for_conditions -> sanitize_sql_array
def sanitize_sql_array(ary) statement, *values = ary if values.first.is_a?(Hash) and statement =~ /:\w+/ replace_named_bind_variables(statement, values.first) elsif statement.include?('?') replace_bind_variables(statement, values) else statement % values.collect { |value| connection.quote_string(value.to_s) } end end
引数のary(先程のconditions)の一番最初の項目の型により処理をわけてる。
- Hashなら、replace_named_bind_variables
- statement(文字列)の中に '?' が入っていたら、replace_bind_variables
- 上記2つに当てはまらなければ、String.% を使って、フォーマットを指定して文字列を埋め込む
ActiveRecord::Base#replace_named_bind_variables
find() -> .... -> sanitize_sql_for_conditions -> sanitize_sql_array -> replace_named_bind_variables
def replace_named_bind_variables(statement, bind_vars) #:nodoc: statement.gsub(/(:?):([a-zA-Z]\w*)/) do if $1 == ':' # skip postgresql casts $& # return the whole match elsif bind_vars.include?(match = $2.to_sym) quote_bound_value(bind_vars[match]) else raise PreparedStatementInvalid, "missing value for :#{match} in #{statement}" end end end
ようやく、終わりに近づいてきたぞ。
statement に :xxx というモノが有れば、bind_vars[:xxx]を見つけてquote_bound_value()に渡す。
ActiveRecord::Base#quote_bound_value
find() -> .... -> sanitize_sql_array -> replace_named_bind_variables -> quote_bind_value
def quote_bound_value(value) #:nodoc: if value.respond_to?(:map) && !value.acts_like?(:string) if value.respond_to?(:empty?) && value.empty? connection.quote(nil) else value.map { |v| connection.quote(v) }.join(',') end else connection.quote(value) end end
valueにmapメソッドがあれば、1,2,'string',4のような形式にする
そうでなければ、quoteした値を返す。
connection.quote()は接続するDBによって違うのですが、デフォルトの定義を見てみましょう。
このデフォルトの定義は、各connection側で再定義し直せば上書きすることが可能です。
ActiveRecord::ConnectionAdapters::AbstractAdapter#quote
find() -> .... -> replace_named_bind_variables -> quote_bind_value -> connection.quote
class AbstractAdapter include Quoting, DatabaseStatements, SchemaStatements include QueryCache include ActiveSupport::Callbacks .....
クラスの定義をみるとQuotingをincludeしているので、Quotingの定義を見てみます。
ActiveRecord::ConnectionAdapters::Quoting
find() -> .... -> replace_named_bind_variables -> quote_bind_value -> connection.quote(Quoting included)
ちょっと長いけど、先に説明するのでソースを眺めてみてください。
quoteで渡されたvalueの型により場合分け
- String,ActiveSupport::Multibyte::Charsの場合
- カラムのタイプが、:binaryだったときは、string_to_binaryを使って、バイナリにしたものをquoteする(これはあんまり使わないかも)
- DBのカラムのタイプがinteger,floatの場合(渡されたRubyのClassはString)、to_i, to_f で数値に変更する
- それ以外(普通の文字列)の場合は、quote_stringを使って、quoteする
- teststring -> 'teststring',
- single quote' string -> 'single quote'' string'
- teststring -> N'teststring' とかもあるかな?
- NilClassの場合
- NULL を返す
- TrueClass,FalseClassの場合
- t,f or 1,0 を返す
- Integer,Floatの場合
- BigDecimalの場合
- to_sの結果を返すが、quoteする必要があるかもよ。
- それ以外の場合(どんなときだ? 定義したclassとか?)
- date,timeとして扱えるときは、 '2010-05-12 00:00:00' とかに変換する
- 扱えないときは quote_stringを使って、quoteする。
module Quoting # Quotes the column value to help prevent # {SQL injection attacks}[http://en.wikipedia.org/wiki/SQL_injection]. def quote(value, column = nil) # records are quoted as their primary key return value.quoted_id if value.respond_to?(:quoted_id) case value when String, ActiveSupport::Multibyte::Chars value = value.to_s if column && column.type == :binary && column.class.respond_to?(:string_to_binary) "#{quoted_string_prefix}'#{quote_string(column.class.string_to_binary(value))}'" # ' (for ruby-mode) elsif column && [:integer, :float].include?(column.type) value = column.type == :integer ? value.to_i : value.to_f value.to_s else "#{quoted_string_prefix}'#{quote_string(value)}'" # ' (for ruby-mode) end when NilClass then "NULL" when TrueClass then (column && column.type == :integer ? '1' : quoted_true) when FalseClass then (column && column.type == :integer ? '0' : quoted_false) when Float, Fixnum, Bignum then value.to_s # BigDecimals need to be output in a non-normalized form and quoted. when BigDecimal then value.to_s('F') else if value.acts_like?(:date) || value.acts_like?(:time) "'#{quoted_date(value)}'" else "#{quoted_string_prefix}'#{quote_string(value.to_yaml)}'" end end end # Quotes a string, escaping any ' (single quote) and \ (backslash) # characters. def quote_string(s) s.gsub(/\\/, '\&\&').gsub(/'/, "''") # ' (for ruby-mode) end # Quotes the column name. Defaults to no quoting. def quote_column_name(column_name) column_name end # Quotes the table name. Defaults to column name quoting. def quote_table_name(table_name) quote_column_name(table_name) end def quoted_true "'t'" end def quoted_false "'f'" end def quoted_date(value) value.to_s(:db) end def quoted_string_prefix '' end end
quoteの詳細もわかったところで、ActiveRecord::Base#sanitize_sql_arrayに一度戻って、次のreplace_bind_variablesを調べよう。
ActiveRecord::Base#sanitize_sql_array
find() -> .... -> replace_named_bind_variables -> quote_bind_value -> connection.quote(Quoting included)
こんなコードだった。
elsif statement.include?('?') replace_bind_variables(statement, values)
どうやら、:conditions=>["column_name = ?", 'name'] みたいな時に使われるようだ。
ActiveRecord::Base#replace_bind_variables
replace_bind_variablesの実装は
def replace_bind_variables(statement, values) #:nodoc: raise_if_bind_arity_mismatch(statement, statement.count('?'), values.size) bound = values.dup statement.gsub('?') { quote_bound_value(bound.shift) } end
引数の数を調べたあとに、statement.gsub で ? を valuesの値で置き換える
quote_bound_value はさっきみたので、華麗にスルーします。
さて、また戻ります。
今まで追ってきたところは、ActiveRecord::sanitize_sql_for_conditionsなので、もう一度ソースを見てみよう。
ActiveRecord::Base#sanitize_sql_for_conditions
find() -> .... -> add_conditions! -> merge_conditions -> sanitize_sql_for_conditions
def sanitize_sql_for_conditions(condition, table_name = quoted_table_name) return nil if condition.blank? case condition when Array; sanitize_sql_array(condition) when Hash; sanitize_sql_hash_for_conditions(condition, table_name) else condition end end
ああ、思い出した。sanitize_sql_array()を深く追っていたんだ。
次は、sanitize_sql_hash_for_conditionsを追ってみます。
ActiveRecord::Base#sanitize_sql_hash_for_conditions
find() -> .... -> merge_conditions -> sanitize_sql_for_conditions -> sanitize_sql_hash_for_conditions
def sanitize_sql_hash_for_conditions(attrs, default_table_name = quoted_table_name) attrs = expand_hash_conditions_for_aggregates(attrs) conditions = attrs.map do |attr, value| table_name = default_table_name unless value.is_a?(Hash) attr = attr.to_s # Extract table name from qualified attribute names. if attr.include?('.') attr_table_name, attr = attr.split('.', 2) attr_table_name = connection.quote_table_name(attr_table_name) else attr_table_name = table_name end attribute_condition("#{attr_table_name}.#{connection.quote_column_name(attr)}", value) else sanitize_sql_hash_for_conditions(value, connection.quote_table_name(attr.to_s)) end end.join(' AND ') replace_bind_variables(conditions, expand_range_bind_variables(attrs.values)) end
expand_hash_conditions_for_aggregatesは、conditionsで指定したhashのキーから、DBのカラムへマッピングしたものを返します。
マッピング自体は、composed_ofで指定します。
例文としては
class Customer < ActiveRecord::Base composed_of :balance, :class_name => "Money", :mapping => %w(balance amount) end
という定義があったら、Customer.find(:all,conditions=>[:balance,'1000'] とかできるってことかな。(あんまり自信がない。間違えてたら指摘してください。)
処理している内容は
- conditionsのvalueがHashなら、attribute_conditionを呼び出します。
- そうでなければ、自分自身(sanitize_sql_hash_for_conditions)を呼び出して、再帰的に処理します。
- 最後にそれぞれの値を' AND 'を使ってJOINします
attribute_conditionのコードも見てみます。
AtiveRecord::Base#attribute_condition
find() -> .... sanitize_sql_for_conditions -> sanitize_sql_hash_for_conditions -> attribute_condition
def attribute_condition(quoted_column_name, argument) case argument when nil then "#{quoted_column_name} IS ?" when Array, ActiveRecord::Associations::AssociationCollection, ActiveRecord::NamedScope::Scope then "#{quoted_column_name} IN (?)" when Range then if argument.exclude_end? "#{quoted_column_name} >= ? AND #{quoted_column_name} < ?" else "#{quoted_column_name} BETWEEN ? AND ?" end else "#{quoted_column_name} = ?" end end
quoted_column_nameには、conditionsに渡したキーの名前(カラム名)、argumentにはキーに対応した値が入ってきます。
- argumentがnilの場合には、"column_name IS ?"
- Arrayの場合には、"column_name IN (?)"
- Rangeの場合には、"column_name >= ? AND column_name < ?" や "column_name BETWEEN ? AND ?" や "column_name = ?"
を返します。
結果として、replace_bind_variables() を呼ぶ前まで conditions は、"column_name1 = ? AND column_name2 IN (?)"というような文字列になります。
replace_bind_variables()は先程見たように、? をgsub()してquoteされたvalueに置き換えます。
なので、{:column_name1 => 1, :column_name2 => [1,'2']} が "column_name = 1 AND column_name IN (1,'2')" という文字列になります。
今まで、add_conditions!を追ってきたので、もう一度戻ります。
ActiveRecord::Base#construct_finder_sql
find -> find_initial -> find_every -> construct_finder_sql
def construct_finder_sql(options) scope = scope(:find) sql = "SELECT #{options[:select] || (scope && scope[:select]) || default_select(options[:joins] || (scope && scope[:joins]))} " sql << "FROM #{options[:from] || (scope && scope[:from]) || quoted_table_name} " add_joins!(sql, options[:joins], scope) add_conditions!(sql, options[:conditions], scope) add_group!(sql, options[:group], options[:having], scope) add_order!(sql, options[:order], scope) add_limit!(sql, options, scope) add_lock!(sql, options, scope) sql end
あとは、add_group!, add_order!, add_limit!, add_lock! を呼んでいます。
上から順にソースを見ていきます。
ActiveRecord::Base#add_group!
find -> find_initial -> find_every -> construct_finder_sql -> add_group!
def add_group!(sql, group, having, scope = :auto) if group sql << " GROUP BY #{group}" sql << " HAVING #{sanitize_sql_for_conditions(having)}" if having else scope = scope(:find) if :auto == scope if scope && (scoped_group = scope[:group]) sql << " GROUP BY #{scoped_group}" sql << " HAVING #{sanitize_sql_for_conditions(scope[:having])}" if scope[:having] end end end
findで渡した、:group,:havingを使って、sanitize_sql_for_conditionsを呼んでいます。
sanitize_sql_for_conditionsはhashなどを受け取って、サニタイズされたSQLを作ります。
結果として、サニタイズされた having句を作ります。
ActiveRecord::Base#add_order!
find -> find_initial -> find_every -> construct_finder_sql -> add_order!
def add_order!(sql, order, scope = :auto) scope = scope(:find) if :auto == scope scoped_order = scope[:order] if scope if order sql << " ORDER BY #{order}" if scoped_order && scoped_order != order sql << ", #{scoped_order}" end else sql << " ORDER BY #{scoped_order}" if scoped_order end end
特筆することはないですね。そのまま:orderに渡されたものを、order句に書くだけです。
ActiveRecord::Base#add_limit!
find -> find_initial -> find_every -> construct_finder_sql -> add_limit!
def add_limit!(sql, options, scope = :auto) scope = scope(:find) if :auto == scope if scope options[:limit] ||= scope[:limit] options[:offset] ||= scope[:offset] end connection.add_limit_offset!(sql, options) end
connection.add_limit_offset!を呼んでいます。
この定義はどこにあるかというと、AbstractAdapterでincludeされているDatabaseStatementsに有ります。
class AbstractAdapter include Quoting, DatabaseStatements, SchemaStatements
ActiveRecord::ConnectionAdapters::DatabaseStatements#add_limit_offset!
find -> ... -> construct_finder_sql -> connection.add_limit! -> add_limit_offset!
def add_limit_offset!(sql, options) if limit = options[:limit] sql << " LIMIT #{sanitize_limit(limit)}" if offset = options[:offset] sql << " OFFSET #{offset.to_i}" end end sql end
末尾に LIMIT n,m や OFFSET n を追加しています。
一応、sanitize_limitの中も確認しておきます。
ActiveRecord::ConnectionAdapters::DatabaseStatements#sanitize_limit
find -> ... -> connection.add_limit! -> add_limit_offset -> sanitize_limit
def sanitize_limit(limit) if limit.to_s =~ /,/ limit.to_s.split(',').map{ |i| i.to_i }.join(',') else limit.to_i end end
to_iで数値に変換しているだけですね。
ActiveRecord::Base#add_lock!
find -> find_initial -> find_every -> construct_finder_sql -> add_lock!
def add_lock!(sql, options) case lock = options[:lock] when true; sql << ' FOR UPDATE' when String; sql << " #{lock}" end end
options[:lock] の値によって、FOR UPDTEか渡された文字列をクエリの末尾に付け加えてます。
ActiveRecord::Base#add_lock!
長かったconstruct_finder_sqlも終わりです。
最後に作成したクエリを返しています。
find -> find_initial -> find_every -> construct_finder_sql
def construct_finder_sql(options) ..... add_lock!(sql, options, scope) sql end
ActiveRecord::Base#find_every
コードを忘れてしまったので、もう一度載せます。
find -> find_initial -> find_every
def find_every(options) include_associations = merge_includes(scope(:find, :include), options[:include]) if include_associations.any? && references_eager_loaded_tables?(options) records = find_with_associations(options) else records = find_by_sql(construct_finder_sql(options)) if include_associations.any? preload_associations(records, include_associations) end end records.each { |record| record.readonly! } if options[:readonly] records end
ActiveRecord::Base#find_by_sql
def find_by_sql(sql) connection.select_all(sanitize_sql(sql), "#{name} Load").collect! { |record| instantiate(record) } end
sanitize_sql呼んでいるけど、意味あるんかな? Arrayでもないし、Hashでもないので、そのまま文字列を返すだけ。
とりあえず、connection.select_all() に渡す時点でsanitizeされているのは確認できました。
長くなってしまったので、create/saveなどは次の記事で書く予定。