環境
Ruby 2.5
Rails 5.2.1
目的
Railsでアプリケーションを書く時、このメソッドはクエリを発行するかどうか、度々調べていて、ちゃんと覚えきれていないので、ちょっと調べようと思い、まとめてみることにしました。
ActiveRecord::FinderMethodsに絞ったのは読みやすそうだなと思ったからです。思ったより長くなり完全には調べきれるには気力を消耗しすぎたので、間違っている点があればご指摘頂けると幸いです。
参考
ActiveRecord::FinderMethods
Ruby on Rails API
まとめ表
記事本文はかなり長くなったので結果だけ見たい方はこちら。
(例外発生時のクエリ有無は含んでいません。)
メソッド名 | loadedなインスタンスに対する メソッド呼び出しでクエリ発行しないもの |
その他一般的用途で クエリ発行しない条件 |
例外的な(一般的な用途でない) クエリ発行しない条件 |
---|---|---|---|
take/take! | ○ | 1. 一度引数なしで当該メソッドを呼び出したインスタンスへの、二度目のメソッド呼び出し。 | |
exists? | 下記の場合はクエリを発行しない。(いずれもfalseが返る) 1. 引数にfalseを渡した場合 2. limit(0)をチェーンしていた場合。 |
||
find | 下記の場合はクエリ発行しない。 1. loadedなのインスタンスに対して、blockつきで呼び出した場合(Enumerableのfindが呼ばれる) 2. 引数の先頭に空配列を渡した場合。(空配列が返る) |
||
find_by/find_by! | 例外的に下記の場合はクエリ発行しない。 1. loadedなインスタンスに対して、blank?がtrueになる引数を渡した場合 (ex: users.find_by(nil)) |
||
first/first! | ○ | 1. 一度引数なしで当該メソッドを呼び出したインスタンスへの、二度目のメソッド呼び出し。 | 下記の場合はクエリを発行しない。 (引数なしの場合はnilが、ありの場合は空配列が返る) 1. limit(0)をチェーンしていた場合。 |
second/second! third/third! fourth!/fourth fifth/fifth! forty_two/forty_two! |
○ | 1. 一度メソッドを呼び出したインスタンスへの、二度目のメソッド呼び出し。 | 下記の場合はクエリを発行しない。 1. 内部で呼び出されている数値以下の引数のlimitをチェーンしていた場合。 (ex: forty_twoであれば、41以下の引数をもつlimitをチェーンしていた場合) |
last/last! | ○ | ||
second_to_last/second_to_last! third_to_last/third_to_last! |
○ |
ActiveRecord::FinderMethods
にはどんなメソッドがあるのか?
まずActiveRecord::FinderMethods
にどんなメソッドがあるか調べていきます。rails console
で確認すると全部で25個のメソッドがあることがわかります。(読みやすいように並びを変更しています。)
pry(main)> ActiveRecord::FinderMethods.instance_methods(false)
=> [:take, :take!, :exists?, :find, :find_by, :find_by!,
:first, :first!, :second, :second!, :third, :third!,
:fourth!, :fourth, :fifth, :fifth!, :forty_two, :forty_two!,
:last, :last!, :second_to_last, :second_to_last!,
:third_to_last, :third_to_last!,
:raise_record_not_found_exception!]
pry(main)> ActiveRecord::FinderMethods.instance_methods(false).count
=> 25
:raise_record_not_found_exception!
は例外をあげるメソッドなので、実質24個です。
ActiveRecord::Relation
のloaded?
メソッド
先のメソッドを一つずつ見ていこうと思うのですが、その前にActiveRecord::Relation
のloaded?
メソッドについて少し触れておきます。なぜこのメソッドに触れるのかというと、クエリ発行の有無に関わる場合が多いものだからです。
loaded?
メソッドはクエリ発行をし、オブジェクトを取得したかどうかを確認するメソッドです。
(参考:ActiveRecord::Relationとは一体なんなのか)
loaded?
メソッドの実態は、@loaded
インスタンスへのアクセサーのalias
で、@loaded
のtrue/false/nil
を返すものとなっています。
module ActiveRecord
class Relation
attr_reader :table, :klass, :loaded, :predicate_builder
alias :loaded? :loaded
end
end
それでは@loaded
にどのように値が設定されるかを見ていきます。まずinitialize
時には、false
がセットされます。
def initialize(klass, table: klass.arel_table, predicate_builder: klass.predicate_builder, values: {})
~中略~
# falseが入る
@loaded = false
~中略~
end
その後、load
が呼ばれるとloadedでない場合、内部的にはexec_queries
を呼び出しますが、この中で@loaded
にtrue
が代入されます。
def load(&block)
# loadedでない場合は、クエリが発行される
exec_queries(&block) unless loaded?
self
end
private
def exec_queries(&block)
skip_query_cache_if_necessary do
~中略~
# 終盤でloadedにtrueが入る
@loaded = true
@records
end
end
そのためloadedなものに対して、load
をかけてもクエリは発行されません。
一方、reload
などが呼ばれると内部的には、reset
とload
が走りますが、reset
では@loaded
にnil
が代入されるので、次のload
でクエリが走り、再度@loaded
にtrue
が入ります。
def reload
reset
load
end
def reset
~中略~
@to_sql = @arel = @loaded = @should_eager_load = nil
~中略~
end
このようにクエリ実行すべきかどうかの確認を行なっているのがloaded?
メソッドになります。
それではこれから一つ一つメソッドを下記で見ていきます。
take/take!
take
及びtake!
メソッドは下記のようになっています。take!
はtake
がnil
の場合に例外をあげる以外、違いはありません。
def take(limit = nil)
limit ? find_take_with_limit(limit) : find_take
end
def take!
take || raise_record_not_found_exception!
end
take
は引数がある場合は、find_take_with_limit
を、ない場合はfind_take
を呼んでいます。これらはどのようになっているかというと下記になります。
def find_take
if loaded?
records.first
else
@take ||= limit(1).records.first
end
end
def find_take_with_limit(limit)
if loaded?
records.take(limit)
else
limit(limit).to_a
end
end
早速loaded?
が出てきましたね。
loadedなインスタンスに対して、
・find_take
の場合は、recordsの最初のインスタンス
(records.first
はArray#first
なのでクエリ発行しない。)
・find_take_with_limit
の場合は、recordsの先頭から引数個分の配列
(同じくArray#take
)
を取り出すことがわかります。
一方load済みでない場合は、find_take
とfind_take_with_limit
で異なります。
find_take
の場合は、@take
でキャッシュしており、キャッシュがあればそれを、ない場合はlimit(1)
のクエリ発行することがわかります。
一方、find_take_with_limit
の場合はいずれの場合も毎回limit
を発行します。
実際にテストデータで実験してみるとよくわかります。
pry(main)> users = User.where(created_at: 2.months.ago..1.months.ago);
# whereでloadしていないので@loadedはfalse
pry(main)> users.take;
[DEBUG] SELECT `users`.* FROM `users` WHERE `users`.`created_at` BETWEEN '2018-12-10 15:04:02' AND '2019-01-10 15:04:02' LIMIT 1`
pry(main)> users.take;
# 引数なしの場合、二度目はクエリ発行されない
pry(main)> users.take(2);
[DEBUG] SELECT `users`.* FROM `users` WHERE `users`.`created_at` BETWEEN '2018-12-10 15:04:02' AND '2019-01-10 15:04:02' LIMIT 2`
pry(main)> users.take(2);
[DEBUG] SELECT `users`.* FROM `users` WHERE `users`.`created_at` BETWEEN '2018-12-10 15:04:02' AND '2019-01-10 15:04:02' LIMIT 2`
# 引数ありの場合、二度目もクエリ発行される
結果的にはクエリ発行されない条件は下記になります。
1. loadedなインスタンスに対する、take/take!呼び出し。
2. 一度引数なしのtake/take!を呼び出したインスタンスへの、二度目の引数なしのtake/take!呼び出し。
exists?
exists?
メソッドは下記のようになっています。
def exists?(conditions = :none)
if Base === conditions
raise ArgumentError, <<-MSG.squish
You are passing an instance of ActiveRecord::Base to `exists?`.
Please pass the id of the object by calling `.id`.
MSG
end
return false if !conditions || limit_value == 0
if eager_loading?
relation = apply_join_dependency(eager_loading: false)
return relation.exists?(conditions)
end
relation = construct_relation_for_exists(conditions)
skip_query_cache_if_necessary { connection.select_value(relation.arel, "#{name} Exists") } ? true : false
rescue ::RangeError
false
end
結構長いですが、自分なりに翻訳すると下記であろうと思います。
def exists?(conditions = :none)
if Base === conditions
# ActiveRecordのインスタンスが渡されたらエラーを吐く。
end
# 条件にfalseもしくは、チェーンでlimit(0)を渡していた場合、falseが返る。
return false if !conditions || limit_value == 0
if eager_loading?
# eager_loadしていた場合、eager_loadしたものに対して、exists?する。
# (eager_load先のものを条件にすることが可能。)
end
# relationを構築する。
relation = construct_relation_for_exists(conditions)
# 必要であれば、クエリキャッシュをスキップする。(skip_query_cache_if_necessaryの内容をみる限り、block内は実行される。)
skip_query_cache_if_necessary { connection.select_value(relation.arel, "#{name} Exists") } ? true : false
rescue ::RangeError
false
end
そのため、exists?
において、例外が出る場合を除いて、クエリが発行されない条件は下記の二つになります。いずれも例外的で普段使わないと思うので、基本的にクエリ発行があると考える方が自然ですね。
1. 引数にfalseを渡した場合
2. limit(0)をチェーンしていた場合。
実際に実験してみると確かにSQLは発行されていません。
pry(main)> User.exists?;
[DEBUG] SELECT 1 AS one FROM `users` LIMIT 1
# 普通にやるとクエリ発行される
pry(main)> User.exists?(false);
# falseを渡すとクエリ発行されない
pry(main)> User.limit(0).exists?;
# limit(0)をチェーンしてもクエリ発行されない
pry(main)> User.all.limit(1).exists?;
[DEBUG] SELECT 1 AS one FROM `users` LIMIT 1
# limit(1)だとクエリ発行される
find
find
メソッドは下記のようになっています。
def find(*args)
return super if block_given?
find_with_ids(*args)
end
findはこの部分は結構シンプルです。findはblockが渡された場合、Enumerableのfindとして動作します。その他の場合は、find_with_ids
が呼ばれます。find_with_ids
は下記のようになっています。
def find_with_ids(*ids)
raise UnknownPrimaryKey.new(@klass) if primary_key.nil?
expects_array = ids.first.kind_of?(Array)
return [] if expects_array && ids.first.empty?
ids = ids.flatten.compact.uniq
model_name = @klass.name
case ids.size
when 0
error_message = "Couldn't find #{model_name} without an ID"
raise RecordNotFound.new(error_message, model_name, primary_key)
when 1
result = find_one(ids.first)
expects_array ? [ result ] : result
else
find_some(ids)
end
rescue ::RangeError
error_message = "Couldn't find #{model_name} with an out of range ID"
raise RecordNotFound.new(error_message, model_name, primary_key, ids)
end
これもざっくり自分なりに翻訳すると下記になります。
def find_with_ids(*ids)
# primary_keyがnilの場合、例外をあげる。
# 渡されたidsのうち、先頭が配列かつ、空の場合に空配列を返す。
expects_array = ids.first.kind_of?(Array)
return [] if expects_array && ids.first.empty?
# 配列を整理
ids = ids.flatten.compact.uniq
model_name = @klass.name
case ids.size
when 0
# 整理した配列の中身が空だった場合、(ex: User.find(nil, nil)を渡した場合)例外をあげる。
when 1
# find_oneを呼び、引数が配列だった場合はインスタンス一つの配列で(ex: User.find([2]))、
# 単数だった場合はインスタンスを返す。
result = find_one(ids.first)
expects_array ? [ result ] : result
else
# find_someを呼ぶ。
find_some(ids)
end
rescue ::RangeError
# RangeErrorをrescue
end
find_one
では、中でprimary_keyを条件としたwhere
と引数なしのtake
が呼ばれており、条件ありのwhereをチェーンしているので、クエリが発行されます。
find_some
では、中でも同じく条件ありのwhere
とto_a
が呼ばれており、クエリが発行されます。
find_with_ids
ではメソッドの先頭部分で、渡されたidsのうち、先頭が配列かつ、空の場合に空配列を返す
ようになっています。そのためこの場合はクエリ発行前に空配列が返るので、クエリは発行されません。実際に実験してみると確認できます。
pry(main)> User.find([1],2,3,4);
[DEBUG] User Load (3.6ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2, 3, 4)`
pry(main)> User.find([],2,3,4);
# 1つ目が空配列だった場合、他の値がなんであれクエリ発行されない
pry(main)> User.find([],2,3,4)
=> []
# 返り値は空配列
結論として、find
において、例外が出る場合を除いて、クエリが発行されない条件は下記になります。
1. loadedなインスタンスに対して、blockつきで呼び出した場合(Enumerableのfind)
2. 引数の先頭に空配列を渡した場合。(空配列が返る)
find_by/find_by!
find_by
及びfind_by!
メソッドは下記のようになっています。find_by
とfind_by!
の違いは例外時を除くとtake
かtake!
かだけですね。
def find_by(arg, *args)
where(arg, *args).take
rescue ::RangeError
nil
end
def find_by!(arg, *args)
where(arg, *args).take!
rescue ::RangeError
raise RecordNotFound.new("Couldn't find #{@klass.name} with an out of range value",
@klass.name, @klass.primary_key)
end
この場合はwhere
に対してtake
をチェーンしているので、基本的にはクエリが発行されます。しかし、where
とtake
の性質からある場合についてはクエリが発行されません。
take
については既に見たので、where
について見てみましょう。where
は下記のようになっています。
def where(opts = :chain, *rest)
if :chain == opts
WhereChain.new(spawn)
elsif opts.blank?
self
else
spawn.where!(opts, *rest)
end
end
where
は結構複雑なので深入りはしませんが、一点着目して欲しいポイントがあります。それはopts.blank?
の場合、self
を返すということです。ここではself
を返すだけなのでもちろんクエリ発行はしません。そして、take
はロード済みである場合はクエリ発行しないのでした。
つまり、load済みのものに対して、find_by
の引数にblank?
なものを渡せばクエリ発行はされません。実際に実験したのが下記です。
pry(main)> users = User.where(created_at: 2.months.ago..1.months.ago).load;
pry(main)> users.find_by(nil);
pry(main)> users.find_by([]);
# Object.blank? == trueなものを渡すとクエリ発行されない
使い道は全くないですが、面白いです。
find_by/find_by!
の場合、クエリが発行されない条件は下記です。
1. loadedなインスタンスに対して、blank?がtrueになる引数を渡した場合(users.find_by(nil)など)
first/first!
さて番号系メソッドのfirst/first!
です。他の番号系メソッドと違い、first/first!
だけ引数に数値を取れます。
内容的には下記のようになります。first!
はfirst
がnil
の場合に例外をあげるだけですね。
def first(limit = nil)
if limit
find_nth_with_limit(0, limit)
else
find_nth 0
end
end
def first!
first || raise_record_not_found_exception!
end
first
は引数がある場合とない場合で呼び出すメソッドが異なっています。それらのメソッドは下記になります。
def find_nth(index)
@offsets[offset_index + index] ||= find_nth_with_limit(index, 1).first
end
def find_nth_with_limit(index, limit)
if loaded?
records[index, limit] || []
else
relation = ordered_relation
if limit_value
limit = [limit_value - index, limit].min
end
if limit > 0
relation = relation.offset(offset_index + index) unless index.zero?
relation.limit(limit).to_a
else
[]
end
end
end
find_nth
はfind_nth_with_limit
を使用しており、なおかつ結果をインスタンス変数に格納していますね。find_nth_with_limit
を自分なりに翻訳すると下記になります。
def find_nth_with_limit(index, limit)
if loaded?
# loadedの場合、recordsから特定のindexから特定数取り出す。nilの場合は空配列。
else
relation = ordered_relation
if limit_value
# limitメソッドを間に挟んでいた場合、limitメソッドの引数からindexを引いたものとfirstの引数の小さい方をlimitとして取る。
end
if limit > 0
# offset_indexとindex数を足して、offsetする
relation = relation.offset(offset_index + index) unless index.zero?
relation.limit(limit).to_a
else
# limitが0以下の場合は空配列を返す
end
end
end
上記からfirstのクエリ発行しない条件は下記になります。
1. loadedなインスタンスに対する、first/first!呼び出し。
2. 一度引数なしのfirst/first!を呼び出したインスタンスへの、二度目の引数なしのfirst/first!呼び出し。
3. limit(0)をチェーンしていた場合。
second/second!/third/third!/fourth!/fourth/fifth/fifth!/forty_two/forty_two!
さて、first以外の番号系のメソッドですね。forty_two
だけ少し謎いですね…
(今回初めて知った)
こちらは基本的にはfirstと同じですが、引数は取れません。これらは内部的なメソッド呼び出しの引数が異なる以外は同じなので、サンプルにforty_two/forty_two!をみてみましょう。
def forty_two
find_nth 41
end
def forty_two!
forty_two || raise_record_not_found_exception!
end
やっていることはfind_nth
に特定番号の引数を渡しているだけですね。ほとんとfirst
と同じです。そのため、クエリ発行しない条件は下記になります。
1. loadedなインスタンスに対する、メソッド呼び出し。
2. 一度メソッドを呼び出したインスタンスへの、二度目の同一メソッド呼び出し。
3. 内部で呼び出されている数値以下の引数のlimitをチェーンしていた場合。
(ex: forty_twoであれば、41以下の引数をもつlimitをチェーンしていた場合)
last/last!
残りも少なくなってきました。お次はlast/last!
です。メソッド内容は下記になります。last!
はnil
の場合に例外をあげるだけですね。
def last(limit = nil)
return find_last(limit) if loaded? || has_limit_or_offset?
result = ordered_relation.limit(limit)
result = result.reverse_order!
limit ? result.reverse : result.first
end
def last!
last || raise_record_not_found_exception!
end
last
はloadedもしくはlimitかoffsetがある場合と、そうでない場合で挙動が異なります。まず、そうでない場合をみていきましょう。
そうでない場合は内容的にはそれほど難しくなさそうですね。last
に引数がある場合、引数をlimit数としてセットし、reverse_order
で内容を取得します。そして、引数なしの場合は、一つ目を、ありの場合は、reverse_orderで取得してきた内容をわざわざreverse
メソッドで入れ替えています。おかげで、私たちはlast(2)
と呼び出すと、最後から二つを順番通り(逆順ではなく)得ることができるということですね。そのためこの場合はクエリ発行されそうです。
続いて、loadedもしくはlimit
かoffset
がある場合を見ていきます。この場合は、find_last
メソッドを呼び出しています。
def find_last(limit)
limit ? records.last(limit) : records.last
end
たったこれだけです。超シンプルです。さて、loadedの場合はArray#last
が呼ばれているのでクエリ発行はやはりされません。それではlimit
やoffset
の場合はどうでしょうか?。
この場合は、要するにクエリ発行の際にDESC
で取ったりせずに単純にoffset
やlimit
で取ってきて、それを後ろから特定数取るということをしているだけなのです。実際のクエリを見るとわかりやすいです。
[70] pry(main)> User.last;
[DEBUG] SELECT `users`.* FROM `users` ORDER BY `users`.`id` DESC LIMIT 1`
[71] pry(main)> User.offset(2).limit(3).last;
[DEBUG] SELECT `users`.* FROM `users` LIMIT 3 OFFSET 2
offset
とlimit
を使っている場合は、DESC
が入っていませんよね。そのため、limit
やoffset
がある場合はクエリが発行されます。これらを踏まえるとクエリを発行しない条件は下記になります。
1. loadedなインスタンスに対する、メソッド呼び出し。
first
と違ってlast
の場合は、インスタンス変数を保持していないので、二度目の呼び出しでもクエリは発行されますし、limit(0)
をつけてもクエリ発行されます。
second_to_last/second_to_last!/third_to_last/third_to_last!
ようやく最後のメソッドです。最後まで見てくださって頂き誠にありがとうございます。私もこの時点で既に記事を書き始めて調査含め、6時間が経過しておりそろそろ終わりたいです。笑
メソッド内容を見てみましょう。second_to_last/second_to_last!
とthird_to_last/third_to_last!
は内部的には引数が異なるだけなので、second_to_last/second_to_last!
を見ていきます。
def second_to_last
find_nth_from_last 2
end
def second_to_last!
second_to_last || raise_record_not_found_exception!
end
おなじみのsecond_to_last!
の場合は、second_to_last
がnil
の場合に例外をあげるだけですね。それでは実態のメソッドのfind_nth_from_last
を見ていきます。
def find_nth_from_last(index)
if loaded?
records[-index]
else
relation = ordered_relation
if equal?(relation) || has_limit_or_offset?
relation.records[-index]
else
relation.last(index)[-index]
end
end
end
loadedの場合はおなじみですね。普通にrecords
から特定インデックスの場所を取り出すだけですね。そのためクエリ発行はされません。
問題は、loadedでない場合です、if
の部分が少しややこしいですが、これは要するに、現状保持しているrelation
の状態がordered_relation
と同じかどうか判定しています。ordered_ralation
では、order
の指定がなく、primary_key
が存在する場合は、primary_key
のasc
でorder
するように指定しています。それ以外の場合はインスタンスをそのまま返しています。
def ordered_relation
if order_values.empty? && primary_key
order(arel_attribute(primary_key).asc)
else
self
end
end
すなわち先のif
の戻ると、何かしらのorder
をしているもしくは、limit
やoffset
をしている場合は、そのorder
やlimit/offset
でクエリ発行をし、そこから特定インデックスの場所を取るということをしており、逆に指定がない場合は、primary_key
のasc
でレコードを引いてきて、それに対して、特定インデックス分の引数を与えたlast
メソッドを呼び出して、そこからさらに特定インデックスで取り出すということをしています。
relation = ordered_relation
if equal?(relation) || has_limit_or_offset?
relation.records[-index]
else
relation.last(index)[-index]
end
実際にクエリを見ると、order
なしの場合は、特定インデックス分のlimit
が入っており、order
がありlimit
がない場合は一旦全て引いてきていることがわかります。
pry(main)> User.second_to_last;
[DEBUG] SELECT `users`.* FROM `users` ORDER BY `users`.`id` DESC LIMIT 2`
pry(main)> User.order(id: :desc).second_to_last;
[DEBUG] SELECT `users`.* FROM `users` ORDER BY `users`.`id` DESC`
# limitが付いていない。全てのレコードを取得する。
全レコードを取ってきているため、レコード数が多いテーブルに対して、気軽に引くと痛い目にあいます。last
はきちんとlimit
をつけてくれるのですが、second_to_last
等を使う場合は要注意です。
結果として、クエリを発行しない条件は下記になります。
1. loadedなインスタンスに対する、メソッド呼び出し。
last
と同じですね。
最後に
以上で終わりです。
想像以上にActiveRecordは奥深く、各メソッドの内部で呼び出されているメソッドは全然調べきれませんでした…
もし異なる点や実験してみたら違う結果が出たり他にクエリ発行しない条件があれば、ご指摘頂けると幸いです。
最後までお読み頂きありがとうございました。