20
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

RailsのActiveRecord::FinderMethodsのSQLクエリ発行の有無について調べる

Last updated at Posted at 2019-02-11

環境

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::Relationloaded?メソッド

先のメソッドを一つずつ見ていこうと思うのですが、その前にActiveRecord::Relationloaded?メソッドについて少し触れておきます。なぜこのメソッドに触れるのかというと、クエリ発行の有無に関わる場合が多いものだからです。

loaded?メソッドはクエリ発行をし、オブジェクトを取得したかどうかを確認するメソッドです。
(参考:ActiveRecord::Relationとは一体なんなのか)

loaded?メソッドの実態は、@loadedインスタンスへのアクセサーのaliasで、@loadedtrue/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を呼び出しますが、この中で@loadedtrueが代入されます。

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などが呼ばれると内部的には、resetloadが走りますが、resetでは@loadednilが代入されるので、次のloadでクエリが走り、再度@loadedtrueが入ります。

def reload
  reset
  load
end

def reset
  ~中略~
  @to_sql = @arel = @loaded = @should_eager_load = nil
  ~中略~
end

このようにクエリ実行すべきかどうかの確認を行なっているのがloaded?メソッドになります。

それではこれから一つ一つメソッドを下記で見ていきます。

take/take!

take及びtake!メソッドは下記のようになっています。take!takenilの場合に例外をあげる以外、違いはありません。

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.firstArray#firstなのでクエリ発行しない。)
find_take_with_limitの場合は、recordsの先頭から引数個分の配列
  (同じくArray#take)
を取り出すことがわかります。

一方load済みでない場合は、find_takefind_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では、中でも同じく条件ありのwhereto_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_byfind_by!の違いは例外時を除くとtaketake!かだけですね。

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をチェーンしているので、基本的にはクエリが発行されます。しかし、wheretakeの性質からある場合についてはクエリが発行されません。

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!firstnilの場合に例外をあげるだけですね。

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_nthfind_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もしくはlimitoffsetがある場合を見ていきます。この場合は、find_lastメソッドを呼び出しています。

def find_last(limit)
  limit ? records.last(limit) : records.last
end

たったこれだけです。超シンプルです。さて、loadedの場合はArray#lastが呼ばれているのでクエリ発行はやはりされません。それではlimitoffsetの場合はどうでしょうか?。

この場合は、要するにクエリ発行の際にDESCで取ったりせずに単純にoffsetlimitで取ってきて、それを後ろから特定数取るということをしているだけなのです。実際のクエリを見るとわかりやすいです。

[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

offsetlimitを使っている場合は、DESCが入っていませんよね。そのため、limitoffsetがある場合はクエリが発行されます。これらを踏まえるとクエリを発行しない条件は下記になります。

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_lastnilの場合に例外をあげるだけですね。それでは実態のメソッドの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_keyascorderするように指定しています。それ以外の場合はインスタンスをそのまま返しています。

def ordered_relation
  if order_values.empty? && primary_key
    order(arel_attribute(primary_key).asc)
  else
    self
  end
end

すなわち先のifの戻ると、何かしらのorderをしているもしくは、limitoffsetをしている場合は、そのorderlimit/offsetでクエリ発行をし、そこから特定インデックスの場所を取るということをしており、逆に指定がない場合は、primary_keyascでレコードを引いてきて、それに対して、特定インデックス分の引数を与えた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は奥深く、各メソッドの内部で呼び出されているメソッドは全然調べきれませんでした…

もし異なる点や実験してみたら違う結果が出たり他にクエリ発行しない条件があれば、ご指摘頂けると幸いです。

最後までお読み頂きありがとうございました。

20
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?