はじめに
普段空気のように finder 系メソッドを使っているけれど、改めて finder 系メソッドは何があったっけ?というのと内部実装について見ていき、何となくしか理解していない部分をちゃんと理解したいと思います。
finder 系メソッドの整理
今回は 2001/10/09 時点の最新の安定バージョン v6.1.4.1
のソースを元に書いていこうと思います。
finder 系メソッドは
- https://github.com/rails/rails/blob/v6.1.4.1/activerecord/lib/active_record/core.rb
- https://github.com/rails/rails/blob/v6.1.4.1/activerecord/lib/active_record/relation/finder_methods.rb
あたりに詰まっています。
core.rb
の方は Person.find(1)
のようにモデルのクラスメソッドとして呼び出す場合で、 finder_methods.rb
の方は Person.all.find(1)
のようにリレーションのメソッドとして呼び出す場合ですが、どちらに定義されているメソッドも使い方は同じです。
定義されている public メソッドを列挙すると
- find
- find_by/find_by!
- take/take!
- first/first!
- last/last!
- second/second!
- third/third!
- fourth/fourth!
- fifth/fifth!
- forty_two/forty_two!
- third_to_last/third_to_last!
- second_to_last/second_to_last!
- exists?
- include? (member?)
- raise_record_not_found_exception!
です。
42 番目のレコードを取得する forty_two
は用途不明なのと、raise_record_not_found_exception!
は直接は使ったことなかったので後で用途など探っていきたいと思います。
また、ここには where
は定義されていません。 where
は https://github.com/rails/rails/blob/v6.1.4.1/activerecord/lib/active_record/relation/query_methods.rb#L634 に定義されており今回紹介する finder 系メソッドの各所で利用されています。
where
は今回は事前知識としてあるものとして進めるが、where
の引数として指定する条件は
- 文字列 + プレースホルダ形式
- キーワード引数形式
- arel_table 形式
辺りで指定するかと思いますのでサンプルだけ載せておきます。
(arel_table 形式の利用については賛否ある1のでここではそういうのもあると触れるだけです)
# 文字列 + プレースホルダ形式
Person.where('age >= ?', 20).to_sql
# => => "SELECT `people `.* FROM `people` WHERE (age >= 20)"
# 文字列 + 名前付きプレースホルダ形式
Person.where('age >= :age', age: 20).to_sql
# => => "SELECT `people `.* FROM `people` WHERE (age >= 20)"
# キーワード引数形式
Person.where(age: 20).to_sql
# => => "SELECT `people `.* FROM `people` WHERE `people`.`age` = 20"
# arel_table 形式
Person.where(Person.arel_table[:age].gteq(20)).to_sql
# => => "SELECT `people `.* FROM `people` WHERE `people`.`age` >= 20"
find
定義
def find(*args)
使い方
引数に指定されたプライマリーキーのレコードを取得する。
プライマリーキーは通常多くの場合では id
をプライマリーキーとしていると思うが、
Person.primary_key
# => "id"
で取得されるカラムがプライマリーキーです。
また、引数は配列や 2 引数以上で指定することも可能で、その場合には、レコードが配列で返ってくる。
Person.find(1, 2)
の時にも配列で返ってくるが、
Person.find([1])
の時にも 1 レコードではあるが配列で返ってくる。
指定されたプライマリーキーが 1 つでも存在しない場合
指定されたプライマリーキーが 1 つでも存在しなかった場合には ActiveRecord::RecordNotFound
が raise されるため、レコードが存在場合の処理も処理を継続したい場合には rescue
するか、後述する find_by
を利用する必要がある。
find_by/find_by!
定義
def find_by(arg, *args)
def find_by!(arg, *args)
使い方
指定された条件にマッチするレコードを 1 つ取得する。
条件は where で紹介している引数の形式が利用できる。
Person.find_by(email: 'test@example.com')
Person.find_by!(email: 'test@example.com')
もちろん id
でも利用できるため
Person.find_by(id: 1)
も問題ない。
指定された条件のレコードが存在しなかった場合
それが find_by
と find_by!
の違いでもあるが、指定された条件のレコードが取得出来なかった場合に
- find_by => 指定された条件のレコードが取得出来なかった場合に
nil
が返る - find_by! => 指定された条件のレコードが取得出来なかった場合に
ActiveRecord::RecordNotFound
が raise される
という違いがある。指定されたレコードが存在しない場合にも処理を継続したい場合には find_by
を、後よろで ActiveRecord::RecordNotFound
を raise したい場合には find_by!
を使っておけば良い。
逆に、 find_by を利用した場合には nil が返る可能性があるので、必ずこの後には nil チェックが必要になってきます。無ければ臭いコード認定をしますので、忘れずにチェックするようにしましょう。
また、前述でも触れたように find
ではレコードが存在しない場合には ActiveRecord::RecordNotFound
が raise されるため、find_by
で書き換えをすれば良い。
指定された条件に複数のレコードがマッチする場合
指定された条件に複数のレコードがマッチする場合は、最初に取得できたレコードが採用される。これは後述の take
の実装でもあるが、順序関係なく DB から取得した際に最初に取得できるレコードになる。そのため DB の実装によっては変わってくる部分でもあるため、ただ 1 つのレコードのみが仕様的に取得出来るような条件で利用すべきです。
以後説明するメソッドでも !
の有無についてのメソッドの動きは同様なので、これ以降は変わった動きが無い限りはあまり詳しくは触れない。
実装的にも
def find_by(arg, *args)
where(arg, *args).take
end
def find_by!(arg, *args)
where(arg, *args).take!
end
のようになっているので、 !
の動作の違いは take
と take!
の違いなので次に行きます。
take/take!
定義
def take(limit = nil)
def take!
使い方
レコードの順序に関係なく、レコードを 1 件 (limit が指定された場合は N 件) 取得する。
take!
には limit
は指定できない。
Person.take
Person.take(3)
Person.take!
指定した件数分取得できない場合
これは find_by で説明した時と同様です。
また、 take!
の実装ではこの記事の最後に説明する raise_record_not_found_exception!
が使われており、このメソッドがレコードが存在しない場合に ActiveRecord::RecordNotFound
を raise してくれる君のようです。
def take!
take || raise_record_not_found_exception!
end
limit と何が違うの問題
take も limit もどちらも SQL 的には LIMIT
が発行されます。
Person.take(20)
# => SELECT `people`.* FROM `people` LIMIT 20
Person.limit(20)
# => SELECT `people`.* FROM `people` LIMIT 20
違いとしては、
- take =>
Array
が返される - limit =>
ActiveRecord::Relation
が返される
かの違いになります。なので、続いてクエリメソッド等をチェインしたい場合には limit
を利用する必要があります。
first/first!
定義
def first(limit = nil)
def first!
使い方
プライマリーキーで並べ替え (ASC) された先頭 1 件 (limit が指定された場合は N 件) 取得する。
first!
には limit
は指定できない。
Person.first
Person.first(3)
Person.first!
以後は、take
と同様なので省略します。
last/last!
定義
def last(limit = nil)
def last!
使い方
プライマリーキーで並べ替え (ASC) された末尾 1 件 (limit が指定された場合は N 件) 取得する。
last!
には limit
は指定できない。
Person.last
Person.last(3)
Person.last!
以後は、take
と同様なので省略します。
second/second!
定義
def second
def second!
使い方
プライマリーキーで並べ替え (ASC) された先頭から 2 件目を取得する。
Person.second
Person.second!
以後は、take
と同様なので省略します。
また、 third, fourth, fifth, forty_two についても 3 件目 4 件目 5 件目 42 件目なので同様です。
ただ、
forty_two って何やねん
コメントに書いてありました
# Find the forty-second record. Also known as accessing "the reddit".
# If no order is defined it will order by primary key.
「銀河ヒッチハイク・ガイド」 2 というアメリカのSFが元ネタで、42は「生命、宇宙、そして万物についての究極の疑問の答え」である数字らしいです。 (だから、、?
third_to_last/third_to_last!
定義
def third_to_last
def third_to_last!
使い方
プライマリーキーで並べ替え (ASC) された末尾から 3 件目を取得する。
Person.third_to_last
Person.third_to_last!
以後は、take
と同様なので省略します。
また、 second_to_last についても末尾から 2 件目なので同様です。
exists?
定義
def exists?(conditions = :none)
使い方
引数で指定された条件のレコードが存在するかを確認します。
また、クエリメソッドのチェーンでも使われ、引数なしの場合にはそれまでのメソッドチェーンのでのクエリ条件のレコードが存在するかという利用が可能。
この利用の仕方を知らなかったのだけど、Integer や String でただ一つの引数を指定すると、プライマリーキーのレコードが存在するかという利用も可能なようでした。
# プライマリーキーが 5 のレコードが存在するか?
Person.exists?(5)
Person.exists?('5')
# 条件に一致するレコードが存在するか?
Person.exists?(['name LIKE ?', "%#{query}%"])
Person.exists?(id: [1, 4, 8])
Person.exists?(name: 'David')
# 常に false を返す (何のため?
Person.exists?(false)
# テーブルにレコードが存在するか?
Person.exists?
# where の条件のレコードが存在するか?
Person.where(name: 'Spartacus', rating: 4).exists?
include?
定義
def include?(record)
使い方
Array#include?
と同じように、引数に指定したレコードが存在するか。
正確に言うと、引数に指定した引数レコードの id
のレコードが存在するかを確認しています。
また、 member?
にエイリアスされているので member?
と書いても良い。
record = Person.first
Person.where(name: 'David').include?(record)
実装を見ると
def include?(record)
if loaded? || offset_value || limit_value || having_clause.any?
records.include?(record)
else
record.is_a?(klass) && exists?(record.id)
end
end
のようになっていて、何故プライマリーキーではなく id
固定なのかは謎です。
raise_record_not_found_exception!
定義
def raise_record_not_found_exception!(ids = nil, result_size = nil, expected_size = nil, key = primary_key, not_found_ids = nil)
使い方
指定された引数にあったメッセージの ActiveRecord::RecordNotFound
を raise してくれるメソッド。
このメソッドを直接呼ぶ機会が無さそうなのに public
なメソッドになっているので一応載せています。
finder 系メソッドでもちょいちょい使われているのでそこでの使われ方を拝借すると
raise_record_not_found_exception!
raise_record_not_found_exception!(id, 0, 1)
raise_record_not_found_exception!(ids, result.size, expected_size)
のような感じ。有用な利用ユースケースあれば教えてほしいです。
まとめ
個人的には take, first, limit の動作の違いが分かっているようで怪しい線だったので、たまには理解しているつもりの箇所も実装を読んでみるのも良さそうと感じた。
あとは forty_two はただのジョークネタだと解決出来たのでひとまず寝れそう。