RuboCopの Rails/InverseOf について調べた

最近、RuboCopへのパッチを投げるようにしているのですが、なんか良さそうなバグは無いかなとissueを漁っていたところ、こんなissueを見つけました。

このissue自体は単なるfalse positiveなので、まぁ対応すれば良いのですが... こっちがその問題のCopが追加されたプルリクエスト。

このCopが教えてくれることは、状況によっては :inverse_of を明示的に指定しないと、意図した挙動をしないことがあるので、その場合にはちゃんと :inverse_of を書きなさいね、ということです。ただ、1個目のissueのコメントで「Railsガイドには :through とか :polymorphic を指定していると、:inverse_of は効かないって書いてあるけど、それでも指定しないとダメなの?」と言っている人もいます。

:inverse_of については、Railsのバージョンアップによってデフォルトで指定しなくても動くようになった経緯などもあって、いつ指定すべきで、いつ指定しなくてもいいのか、いまいち理解が曖昧だったので、ちょっと調べることにしました。

対象バージョン

今回調べた対象バージョンは以下。つまり、これ以外のバージョンではこの記事に書いてあることを鵜呑みにしてはいけません。

  • 4.2.10
  • 5.0.6
  • 5.1.4
  • 5.2.0.beta2

:inverse_of とは何なのか

これを読んでください。

説明が難しいのですが、アソシエーション経由でレコードを参照するときに、同じレコードを参照する場合に、わざわざSQLを発行せずに、既に知っているオブジェクトを使いまわせるようにするためのオプション... といったところでしょうか。例えば、以下のようなモデルの定義において、author.books 経由で作成されたBookのAuthorは、authorと同じオブジェクトになります。

def Author < ApplicationRecord
  has_many :books
end

def Book < ApplicationRecord
  belongs_to :author
end
irb(main):001:0> author = Author.first
irb(main):002:0> book = author.books.build
irb(main):003:0> author.equal? book.author
=> true

一見すると、まぁなんかそうあって欲しいよねって感じなんですが、この挙動は4.1から入ったもので、それ以前では都度SQLを投げて、インスタンスを生成していました。つまり、同じレコードを参照しているはずなのに、そのデータを取得するために、わざわざSQLが発行されていたり、更新したレコードの内容が、アソシエーション経由だと反映されていなかったり、みたいなことが発生してしまいます。

そういった事態を回避するために、4.1以前では、手で :inverse_of を指定することで、この挙動を実現していました。

def Author < ApplicationRecord
  has_many :books, inverse_of: :author
end

Rails/InverseOf とは何なのか

では、Rails/InverseOf は何をしてくれるCopなのでしょうか。実は、4.1からアソシエーション経由のレコードへのアクセスは同じオブジェクトを参照すると言っていましたが、半分は嘘です。うまく使いまわすことができない状況があります。これは上記Railsガイドにも書いてあります。

例えば、以下のようなモデルの定義においては、意図通りの挙動をしません。

def Author < ApplicationRecord
  has_many :books, -> () { order(:created_at) }
end

def Book < ApplicationRecord
  belongs_to :author
end
irb(main):001:0> author = Author.first
irb(main):002:0> book = author.books.build
irb(main):003:0> author.equal? book.author
  Author Load (0.4ms)  SELECT  "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT 1  [["id", 1]]
=> false

出力のログを見ればわかるように、book.author の参照時にSQLが発行されているのがわかります。これを意図通りの挙動するためには、Rails 4.0時代と同じように :inverse_of の指定が必要になります。

def Author < ApplicationRecord
  has_many :books, -> () { order(:created_at) }, inverse_of: :author
end
irb(main):001:0> author = Author.first
irb(main):002:0> book = author.books.build
irb(main):003:0> author.equal? book.author
=> true

このように、明示的に :inverse_of を指定しないといけないのに、指定されていないアソシエーションの定義を見つけてくれるのが、このCopというわけです。RuboCop 0.52.0の時点では、:inverse_of が指定されていない :conditions, :through, :polymorphic, :class_name, :foreign_key を含むアソシエーションに対して警告を出します。

ではRailsガイドの説明は何なのか?

話を冒頭に戻しましょう。1つ目のissueのコメントでは、Railsガイドによると、:through や :polymorphic、:as は :inverse_of と併用できない、となっているのに、なぜ :through に対して Rails/InverseOf Cop は :inverse_of をつけろと警告するの?という質問がありました。
そもそも、これらのオプションに対して、:inverse_of が効かないというのは本当でしょうか。調べてみましょう。

:through

実際にどういった場合に :inverse_of が効果を発揮するのか調べてみたのですが、ドキュメントに書いてありますね... ドキュメントによると、:inverse_of を指定しないと、中間レコードの作成ができないケースがあるそうです。

ActiveRecord::Associations::ClassMethods

Railsガイドにある :through 関連付けの例で見てみましょう。

class Physician < ApplicationRecord
  has_many :appointments
  has_many :patients, through: :appointments
end
 
class Appointment < ApplicationRecord
  belongs_to :physician
  belongs_to :patient
end
 
class Patient < ApplicationRecord
  has_many :appointments
  has_many :physicians, through: :appointments
end
irb(main):001:0> patient = Patient.first
irb(main):002:0> physician = patient.physicians.build
irb(main):003:0> physician.save!
   (0.1ms)  SAVEPOINT active_record_1
  SQL (2.1ms)  INSERT INTO "physicians" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", "2017-12-17 14:35:11.921965"], ["updated_at", "2017-12-17 14:35:11.921965"]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true
irb(main):004:0> physician.patients
  Patient Load (0.6ms)  SELECT "patients".* FROM "patients" INNER JOIN "appointments" ON "patients"."id" = "appointments"."patient_id" WHERE "appointments"."physician_id" = ?  [["physician_id", 2]]
=> #<ActiveRecord::Associations::CollectionProxy []>

:inverse_of を指定してみます。

class Appointment < ApplicationRecord
  belongs_to :physician, inverse_of: :appointments
  belongs_to :patient, inverse_of: :appointments
end
irb(main):001:0> patient = Patient.first
irb(main):002:0> physician = patient.physicians.build
irb(main):003:0> physician.save!
   (0.1ms)  SAVEPOINT active_record_1
  SQL (3.5ms)  INSERT INTO "physicians" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", "2017-12-17 14:36:35.028959"], ["updated_at", "2017-12-17 14:36:35.028959"]]
  SQL (0.8ms)  INSERT INTO "appointments" ("patient_id", "physician_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["patient_id", 1], ["physician_id", 2], ["created_at", "2017-12-17 14:36:35.036274"], ["updated_at", "2017-12-17 14:36:35.036274"]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true
irb(main):004:0> physician.patients
  Patient Load (0.2ms)  SELECT "patients".* FROM "patients" INNER JOIN "appointments" ON "patients"."id" = "appointments"."patient_id" WHERE "appointments"."physician_id" = ?  [["physician_id", 2]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Patient id: 1, created_at: "2017-12-17 13:46:14", updated_at: "2017-12-17 13:46:14">]>

save! の時点で中間レコードが生成されていますね。つまり、:through 関連で :inverse_of が効かないというわけでは無さそうです。ただ、Railsガイドには「:through と併用できない」と書いているので、たしかに :inverse_of を指定しているのは :through の指定されていない belongs_to に対してですし、Railsガイドが嘘をついている、というわけでは無さそう。

確かに、逆に has_many 側に指定したパターンでは、正確に :inverse_of は動作しませんでした。

class Physician < ApplicationRecord
  has_many :appointments, inverse_of: :physician
  has_many :patients, through: :appointments
end
 
class Appointment < ApplicationRecord
  belongs_to :physician
  belongs_to :patient
end
 
class Patient < ApplicationRecord
  has_many :appointments, inverse_of: :patient
  has_many :physicians, through: :appointments
end
class Physician < ApplicationRecord
  has_many :appointments
  has_many :patients, through: :appointments, inverse_of: :physicians
end
 
class Appointment < ApplicationRecord
  belongs_to :physician
  belongs_to :patient
end
 
class Patient < ApplicationRecord
  has_many :appointments
  has_many :physicians, through: :appointments, inverse_of: :patients
end

ちなみに、build せずに create! すれば :inverse_of を指定しなくても中間テーブルを作成できるので、そんなに困ることは無さそう。

irb(main):001:0> patient = Patient.first
irb(main):002:0> physician = patient.physicians.create!
   (0.1ms)  SAVEPOINT active_record_1
  SQL (1.3ms)  INSERT INTO "physicians" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", "2017-12-17 14:46:28.684290"], ["updated_at", "2017-12-17 14:46:28.684290"]]
  SQL (1.3ms)  INSERT INTO "appointments" ("patient_id", "physician_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["patient_id", 1], ["physician_id", 2], ["created_at", "2017-12-17 14:46:28.703182"], ["updated_at", "2017-12-17 14:46:28.703182"]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> #<Physician id: 2, created_at: "2017-12-17 14:46:28", updated_at: "2017-12-17 14:46:28">
irb(main):003:0> physician.patients
  Patient Load (0.3ms)  SELECT "patients".* FROM "patients" INNER JOIN "appointments" ON "patients"."id" = "appointments"."patient_id" WHERE "appointments"."physician_id" = ?  [["physician_id", 2]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Patient id: 1, created_at: "2017-12-17 13:46:14", updated_at: "2017-12-17 13:46:14">]>

:polymorphic

これもRailsガイドのサンプルで試してみます。

class Picture < ApplicationRecord
  belongs_to :imageable, polymorphic: true
end
 
class Employee < ApplicationRecord
  has_many :pictures, as: :imageable
end
 
class Product < ApplicationRecord
  has_many :pictures, as: :imageable
end

まずは :inverse_of 無し。

irb(main):001:0> employee = Employee.first
irb(main):002:0> picture = employee.pictures.build
irb(main):003:0> employee.equal? picture.imageable
  Employee Load (0.2ms)  SELECT  "employees".* FROM "employees" WHERE "employees"."id" = ? LIMIT 1  [["id", 1]]
=> false

employee と picture.imageable は同じレコードのはずなので、 true が返ってきて欲しいのですが、SQLが発行されて、falseが返ってきます。
:inverse_of を指定してみましょう。

class Picture < ApplicationRecord
  belongs_to :imageable, polymorphic: true, inverse_of: :pictures
end
irb(main):001:0> employee = Employee.first
irb(main):002:0> picture = employee.pictures.build
irb(main):003:0> employee.equal? picture.imageable
  Employee Load (1.1ms)  SELECT  "employees".* FROM "employees" WHERE "employees"."id" = ? LIMIT 1  [["id", 1]]
=> false

うーん、ダメそう。ということは、:polymorphic と併用できない、というのも本当みたいですね。

:as

一方で、:polymorphic の例で使っている :as はどうでしょう。こっち側に :inverse_of を足してみましょう。

class Employee < ApplicationRecord
  has_many :pictures, as: :imageable, inverse_of: :imageable
end
irb(main):001:0> employee = Employee.first
irb(main):002:0> picture = employee.pictures.build
irb(main):003:0> employee.equal? picture.imageable
=> true

動いた。どうやらこっちは動くらしい。つまり、 :inverse_of が :as と併用できないというのは間違いのようですね。

ちなみに、この :polymorphic と :as の :inverse_of ですが、5.2 beta2の時点で :inverse_of 無しでも自動的にうまく動くようになっています。これは Rails/InverseOf Copも把握していて、TargetRailsVersionを5.2にすることで、:polymorphic に関する警告は消えます。

余談

Railsガイドが間違っているのでは?ということになると、じゃあ直そうという話になるのですが、これは既に Rails/InverseOf Copを追加した人が rails/rails にパッチを投げているようです。

なので、これがマージされると、:as には :inverse_of を指定しても意味無いって書いてあったぞ!みたいな話が上がってこなくなるかもしれません。しかし、ActiveRecordは本当によく出来てるな...

追記

直した

v0.52.1 ではちゃんとチェックしてくれるはず。