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

利用しているRailsのバージョンはRails 5.2.3で、のちに引用するコードも該当バージョンのものです。

## 何が起きたのか

開発を引き継いだRailsアプリケーション内のモデルで、本来`<関連先モデルの単数形>_id`となっているべき外部キーの入るカラムが`_id`なしで関連先モデルの単数形と同じになっている箇所がありました。

```rb
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`というメソッドです。

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

```ruby: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`メソッド中

```ruby
klass ||= AssociationQueryValue
queries = klass.new(associated_table, value).queries.map do |query|
```

の箇所で関連名を外部キー、つまり規約どおりなら`bar_id`に置き換えて再帰的に`expand_from_hash`を呼び出します。ネストが１重だった場合は再帰的に呼ばれた`expand_from_hash`では最後のelse節に入ってくれます。

しかし、今回のケースでは外部キーの名前も関連名も同じなので、ふたたび`expand_from_hash`を同じ引数で呼び出すことになり無限再帰的にこの１番めのelsif節に入ってしまっていたわけです。

## 解決

もちろん最初からこのようなスキーマ定義を避けるのが一番です。。
あとから見つけたにしても、マイグレーションを行ってRailsの規約に則ったカラム名に変えるのがよいでしょう。

しかし、とりあえず急いでエラーが起きないようにしたいという場合は

```ruby
Bar.where(foo: 1)
Foo.find(1).bars
```

をそれぞれ

```ruby
Bar.where('foo = ?', 1)
```

というように書き換えて、ActiveRecordにクエリを組み立ててもらうのではなく、自分で生SQLの断片を渡すようにすればとりあえず動くようになります。

## 教訓

Railsを使うときはRailに則ったカラム名をつけましょう！