この記事はアピリッツの技術ブログ「DoRuby」から移行した記事です。情報が古い可能性がありますのでご注意ください。
ActiveRecord::Baseのクラスメソッドの中で最も基本的なメソッドの内の1つである find_by 。Railsを使う案件では頻繁にソースコードの中に現れるだろう。 この至極基本的なメソッドに、実は罠が潜んでいて、稀な条件下では発動する可能性がある。 (条件がレアな為、ほとんど発動しないけれど)
find_by
の基本的な使い方
モデル(モデルインスタンスではない)をレシーバにして、引数に条件をハッシュまたはSQL文字列をセット。
返り値はモデルインスタンスとなる。
例1
Artist.find_by(name: 'The Beatles')
# SELECT `artists`.* FROM `artists` WHERE `artists`.`name` = 'The Beatles' LIMIT 1
例2
Artist.find_by("name = 'The Beatles'")
# SELECT `artists`.* FROM `artists` WHERE (name = 'The Beatles') LIMIT 1
罠が発動する条件
モデルに対するテーブルが動的に変化する場合
class Artist < ActiveRecord::Base
self.table_name = :artists_1 # デフォルトの参照テーブル
end
# 参照テーブルを切り替えるクラス
class TableSwitcher
def self.exec
if Artist.table_name == :artists_1
Artist.table_name = :artists_2
else
Artist.table_name = :artists_1
end
end
end
この条件は、Railsの規約には沿っていない。つまりRailsの規約に全て沿っていれば、本記事の内容は気にする必要はない。
大半の案件はRailsの規約には沿うだろうが、大量データ更新の為、2つのテーブルを、TRUNCATE
+INSERT
しながら交互に参照を切り替えていく方針を取っている場合、この問題に直面する事になる。
罠が発動する条件下でfind_by
を使うとどうなるか
Artist.table_name # => artists_1
Artist.find_by(name: 'The Beatles')
# SELECT `artists_1`.* FROM `artists_1` WHERE `artists_1`.`name` = 'The Beatles' LIMIT 1
TableSwitcher.exec # 参照テーブル切り替え
Artist.table_name # => artists_2
Artist.find_by(name: 'The Beatles')
# SELECT `artists_1`.* FROM `artists_1` WHERE `artists_1`.`name` = 'The Beatles' LIMIT 1
参照テーブルは切り替わっているはずなのに発行されるSQL中の参照しているテーブル名が切り替わっていない!
これでは、(テーブルを切り替えて)データを新しても、実際は参照するデータが古いままになってしまう可能性がある。
原因
デフォルトのfind_by
メソッドは発行したSQLをキャッシュする。(ActiveRecord::StatementCache)
よって、参照テーブルを切り替えた後でも、切替前のキャッシュが残って、切替前のテーブルが参照されてしまう。
対処方法
罠が発動する条件に該当するモデルでは、デフォルトのfind_by
が使用されないよう、クラス内でオーバーライドし、where
+first
に書き換える。
※where
メソッドはキャッシュされないので、この対処が可能となる。
class Artist < ActiveRecord::Base
self.table_name = :artists_1 # デフォルトの参照テーブル
class << self
def find_by(*args)
where(*args).first
rescue
super # 例外処理はスーパークラスに任せる
end
end
end
注意点
rubocop を導入している場合は、「where
+first
は find_by
と書き直せ」という警告が出る。
意図的にwhere
とfirst
に分けている訳なので、該当部分だけ以下のようにrubocopチェックの対象外とする必要がある。
def find_by(*args)
# rubocop:disable Rails/FindBy
where(*args).first
rescue
super
end