最近、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 ではちゃんとチェックしてくれるはず。