LoginSignup
0
0

More than 3 years have passed since last update.

ActiveRecord 関連名と外部キーのカラム名が一致しているときに無限ループが起きる問題

Last updated at Posted at 2019-08-08

利用している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というメソッドです。

メソッドの定義全体をまずは引用します。

activerecord/lib/active_record/relation/predicate_builder.rbより抜粋
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に則ったカラム名をつけましょう!

0
0
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
0
0