利用しているRailsのバージョンはRails 5.2.3で、のちに引用するコードも該当バージョンのものです。
何が起きたのか
開発を引き継いだRailsアプリケーション内のモデルで、本来<関連先モデルの単数形>_id
となっているべき外部キーの入るカラムが_id
なしで関連先モデルの単数形と同じになっている箇所がありました。
ActiveRecord::Schema.define(...) do
create_table "foo" do |t|
...
end
create_table "bar" do |t|
...
t.integer "foo" # 本来はfoo_idであってほしい!
end
end
class Foo < ApplicationRecord
has_many :bars, class_name: 'Bar', foreign_key: 'foo' # 本来はfoo_idであってほしい!
end
class Bar < ApplicationRecord
belongs_to :foo, class_name: 'Foo', foreign_key: 'foo'
end
このような状態で、Barモデルに対して外部キーのfoo
で検索を行っている箇所があったのですが、これだと無限ループを起こしてしまうということがわかりました。
> Bar.where(foo: 1)
SystemStackError (stack level too deep)
逆も然り。
> Foo.first.bars
SystemStackError (stack level too deep)
原因
原因はActiveRecordのクエリビルダーが関連名と外部キーのカラム名が一致していることを想定していないためです。
無限ループを起こすメソッドはactiverecord/lib/active_record/relation/predicate_builder.rb
で定義されているexpand_from_hash
というメソッドです。
メソッドの定義全体をまずは引用します。
def expand_from_hash(attributes)
return ["1=0"] if attributes.empty?
attributes.flat_map do |key, value|
if value.is_a?(Hash) && !table.has_column?(key)
associated_predicate_builder(key).expand_from_hash(value)
elsif table.associated_with?(key) # ここに入ってしまう! *1
# Find the foreign key when using queries such as:
# Post.where(author: author)
#
# For polymorphic relationships, find the foreign key and type:
# PriceEstimate.where(estimate_of: treasure)
associated_table = table.associated_table(key)
if associated_table.polymorphic_association?
case value.is_a?(Array) ? value.first : value
when Base, Relation
value = [value] unless value.is_a?(Array)
klass = PolymorphicArrayValue
end
end
klass ||= AssociationQueryValue
queries = klass.new(associated_table, value).queries.map do |query|
expand_from_hash(query).reduce(&:and)
end
queries.reduce(&:or)
elsif table.aggregated_with?(key)
mapping = table.reflect_on_aggregation(key).mapping
values = value.nil? ? [nil] : Array.wrap(value)
if mapping.length == 1 || values.empty?
column_name, aggr_attr = mapping.first
values = values.map do |object|
object.respond_to?(aggr_attr) ? object.public_send(aggr_attr) : object
end
build(table.arel_attribute(column_name), values)
else # 本来はこっちに入ってほしい *2
queries = values.map do |object|
mapping.map do |field_attr, aggregate_attr|
build(table.arel_attribute(field_attr), object.try!(aggregate_attr))
end.reduce(&:and)
end
queries.reduce(&:or)
end
else
build(table.arel_attribute(key), value)
end
end
end
bar_id
のような「カラム名」が与えられたときは、下のメソッド内ではelse節*2の中に入ってくれることを期待します。
しかし、今回のケースはbar
が関連名とも一致しているため、本来はwhereメソッドに「関連名」を渡したときに入るはずの1つめののelsif節*1の中に入ってしまいます。
関連名が渡っているときはexpand_from_hash
メソッド中
klass ||= AssociationQueryValue
queries = klass.new(associated_table, value).queries.map do |query|
の箇所で関連名を外部キー、つまり規約どおりならbar_id
に置き換えて再帰的にexpand_from_hash
を呼び出します。ネストが1重だった場合は再帰的に呼ばれたexpand_from_hash
では最後のelse節に入ってくれます。
しかし、今回のケースでは外部キーの名前も関連名も同じなので、ふたたびexpand_from_hash
を同じ引数で呼び出すことになり無限再帰的にこの1番めのelsif節に入ってしまっていたわけです。
解決
もちろん最初からこのようなスキーマ定義を避けるのが一番です。。
あとから見つけたにしても、マイグレーションを行ってRailsの規約に則ったカラム名に変えるのがよいでしょう。
しかし、とりあえず急いでエラーが起きないようにしたいという場合は
Bar.where(foo: 1)
Foo.find(1).bars
をそれぞれ
Bar.where('foo = ?', 1)
というように書き換えて、ActiveRecordにクエリを組み立ててもらうのではなく、自分で生SQLの断片を渡すようにすればとりあえず動くようになります。
教訓
Railsを使うときはRailに則ったカラム名をつけましょう!