1
0

【Ruby on Rails】rubocopの警告「Rails/InverseOf」から学ぶinverse_ofオプション

Last updated at Posted at 2024-01-31

私は現在、未経験からのエンジニア転職に向けてプログラミングスクールで学習をしている、いしかわと申します。

個人開発中にrubocopからRails/InverseOfと警告されたことをきっかけにActive Recordinverse_ofオプションを用いた双方向関連付けについて学習した内容をアウトプットとしてこちらの記事にしました。
どなたかの参考になれば幸いです。

プログラミング初学者なので、内容に誤り等ある可能性があります
誤りがありましたら教えてくださると幸いです

環境
ruby 3.2.2
Ruby on Rails 7.0.8

双方向関連付けとは

皆さんが良く知る以下のアソシエーション関連付けは双方向関連付けと呼ぶことができます
※まずこれすら知らずにアソシエーションだなんだかんだと言っていた私に石を投げてください

class User < ApplicationRecord
  has_many :books
end

class Book < ApplicationRecord
  belongs_to :user
end

userは複数のbooksを持つ1対多の関係です
上記の関連付けにより双方向からデータを参照(または編集)することができます

user = User.first
book = user.books.first

# object_idの一致
user.object_id #=> 100
book.user.object_id #=> 100

user.name #=> "hoge"
book.user.name #=> "hoge"

#bookオブジェクトから辿ったuserオブジェクトのnameも変更される
user.name = "ishikawa"
user.name #=> "ishikawa"
book.user.name #=> "ishikawa"

関連付けられているUserオブジェクトBookオブジェクトから辿ってもUserオブジェクトから辿っても自動的同一オブジェクトになるようになっています。

RubocopがRails/InverseOfを吐き出す

個人開発を行なっているとき、関連付けを行うとRails/InverseOfrubocopに警告されました
Rails/InverseOfとはなんぞやと調べると

「アソシエーションを定義する際に明示的に:inverse_ofを指定すべき箇所で指定されていないことを警告している」

とのこと
調べると以下のように関連付けを行った場合、自動的に双方向関連付けと認識されない可能性があるらしい
私は以下のようにアソシエーションを行っていました

class Group < ApplicationRecord
  belongs_to :owner, class_name: 'User', foreign_key: 'user_id'
end

class User < ApplicationRecord
  has_many :owned_groups, class_name: 'Group'
end

コンソールで確認してみます

# groupを適当に特定
irb(main):001> group = Group.first
  Group Load (0.4ms)  SELECT "groups".* FROM "groups" ORDER BY "groups"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> 
#<Group:0x0000000107718218

# アソシエーションしているuserであるgroup.ownerを確認
irb(main):003> group.owner
  User Load (0.8ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, email: "test@example.com", created_at: "2023-12-02 04:51:56.263247000 +0900", updated_at: "2023-12-02 04:51:56.263247000 +0900">

# userとprofileを関連付けしてprofileのnameカラムで名前を管理しています
# group.ownerのnameを確認
irb(main):005> group.owner.profile.name
  Profile Load (0.7ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = $1 LIMIT $2  [["user_id", 1], ["LIMIT", 1]]
=> "hoge"

# group.ownerのnameを"変更"に変更。確認。
irb(main):006> group.owner.profile.name = "変更"
irb(main):007> group.owner.profile.name
=> "変更"
irb(main):008> group.owner.profile.save
#...なんやかんやあって
=> true

# userのnameを確認するために先ほどのownerのuserを特定
irb(main):009> user = User.first
  User Load (0.4ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<User id: 1, email: "test@example.com", created_at: "2023-12-02 04:51:56.263247000 +0900", updated_at: ...
# 特定したuserのnameを確認
irb(main):010> user.profile.name
  Profile Load (0.4ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" = $1 LIMIT $2  [["user_id", 1], ["LIMIT", 1]]
=> "変更"

なんやて工藤。うまくいっとるやないか。

双方向関連付けが自動認識されないケース

調べるとigaigaさんがこのような記事を書かれていました。

私はforegin_keyオプションをつけていませんが双方向関連付けがうまくいっているように思えます。
しかしこちらの記事のようにうまくいっていない例もあります。
ここでigaigaさんが行っていたようにuser1user2を特定しobject_idが一致するか確認することにしましました

# user1の特定
irb(main):001> user1 = User.first
  User Load (0.6ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<User id: 1, email: "test@example.com", created_at: "2023-12-02 04:51:56.263247000 +0900", updated_at: ...
irb(main):002> user1.owned_groups
  Group Load (0.4ms)  SELECT "groups".* FROM "groups" WHERE "groups"."user_id" = $1  [["user_id", 1]]
=> 
[#<Group:0x000000010b7562a8
  #...複数のgroupが表示される

# user2の特定
irb(main):004> user2 = user1.owned_groups.first.owner
=> #<User id: 1, email: "test@example.com", created_at: "2023-12-02 04:51:56.263247000 +0900", updated_at: ...
irb(main):004> user2.owned_groups
  Group Load (0.4ms)  SELECT "groups".* FROM "groups" WHERE "groups"."user_id" = $1  [["user_id", 1]]
=> 
[#<Group:0x0000000109b7ac00
  #...複数のgroupが表示される

# user1とuser2のobject_idが一致するか確認
irb(main):005> user1.object_id
=> 114560
irb(main):006> user2.object_id
=> 128480
irb(main):007> user1.object_id == user2.object_id
=> false

object_idを確認すると異なるIDでした。
私は

irb(main):006> group.owner.profile.name = "変更"
=> "変更"
irb(main):007> group.owner.profile.name
=> "変更"
irb(main):008> group.owner.profile.save
#...なんやかんやあって
=> true

この処理がうまくいったので双方向関連付けが成功したと思っていたのですが、値のみが一致しており同一のオブジェクトではなかったということです。
この内容についてChatGPTに確認してみると

Railsにおいて、同一のデータベースレコードを参照しているオブジェクトでも
それらが異なる時点でインスタンス化されたり、異なるコンテキストでロードされたりすると
オブジェクトのobject_idは異なるものになります。
これは、オブジェクトが異なるリクエストや異なる操作によってメモリにロードされた結果として発生します。

この挙動は、Railsがデータベースからオブジェクトをロードする際に
既にメモリ上に存在するオブジェクトを再利用しないためです
(特定のキャッシュメカニズムやスコープが使用されていない限り)。
したがって、userとgroup.ownerが異なるobject_idを持つのは
これらが異なるタイミングやコンテキストでインスタンス化された結果であり
それぞれが独立したオブジェクトとして存在しているためです。

結論として、userオブジェクトとgroup.ownerオブジェクトは
メモリ上では異なるインスタンスですが
同じデータベースレコードの値(このケースではprofile.name)を共有している
という理解が正しいです。

双方向関連付け(bi-directional association)の主な目的は
関連するオブジェクト間でのデータの一貫性を保つことにあります。
つまり、あるオブジェクトを変更した際に、その変更が関連オブジェクトにも適切に
反映される場合、双方向関連付けが正しく機能していると言えます。

つまりinverse_ofオプションをつけなくても「双方向関連付けが失敗している」ということにはならなそうです…難しい…
しかし再度igaigaさんの記事を確認するとこのように以下のように書かれていました。

双方向関連付けがなされていないときには、これに起因するバグや余分なクエリ発行を防ぐために、inverse_ofを書いておいた方が安全です。余分なクエリが発行されているコード例は次のようなコードです。

# 双方向関連付け(inverse_of)あり
user = User.first
user.books #クエリ発行
user2 = user.books.first.author #クエリが発行されない(双方向関連付けの効果)
user2.books #クエリが発行されない(前回のuser.booksのキャッシュ利用)

user.object_id  #=> 124660
user2.object_id #=> 124660

# 双方向関連付け(inverse_of)なし
user = User.first
user.books #クエリ発行
user2 = user.books.first.author #クエリ発行(余分)
user2.books #クエリ発行(余分)

user.object_id  #=> 22680
user2.object_id #=> 24120

inverse_ofオプションをつけることで余計なクエリ発行を防ぐことができるのでやはりforegin_keyオプションをつけるときはinverse_ofオプションをつけたほうがいいという結果に落ち着きました(初めからちゃんと記事読め)

inverse_ofオプションを使用した双方向関連付け

よって今回は以下のようにアソシエーションを設定しました

class Group < ApplicationRecord
  belongs_to :owner, class_name: 'User', foreign_key: 'user_id', inverse_of: :owned_groups
end

class User < ApplicationRecord
  has_many :owned_groups, class_name: 'Group', inverse_of: :owner
end

そしてコンソールでigaigaさんの記事のようにobject_idが一致するか確認しました

# user1の特定
irb(main):001> user1 = User.first
  User Load (0.6ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<User id: 1, email: "test@example.com", created_at: "2023-12-02 04:51:56.263247000 +0900", updated_at: ...
irb(main):002> user1.owned_groups
  Group Load (0.7ms)  SELECT "groups".* FROM "groups" WHERE "groups"."user_id" = $1  [["user_id", 1]]
=> 
[#<Group:0x000000010635a8e8
#複数グループが表示される...

# user2の特定
# クエリが発行されていない!!!
irb(main):004> user2 = user1.owned_groups.first.owner
=> #<User id: 1, email: "test@example.com", created_at: "2023-12-02 04:51:56.263247000 +0900", updated_at: ...
irb(main):005> user2.owned_groups
=> 
[#<Group:0x000000010635a8e8
#複数グループが表示される...

# user1とuser2のobject_idの一致確認
irb(main):006> user1.object_id
=> 153520
irb(main):007> user2.object_id
=> 153520
irb(main):008> user1.object_id == user2.object_id
=> true

このような結果になりすっきりできました!!!!!

結論

inverse_ofオプションはRails4.1以降では自動でつけてくれるようになったそうですが、:conditions, :through, :polymorphic, :foreign_key などを使用した場合はinverse_ofオプションをつけたほうが無駄なクエリの発行を防げるためので、つけたほうが良さそうです。

参考記事

https://railsguides.jp/association_basics.html
https://zenn.dev/igaiga/books/rails-practice-note/viewer/ar_inverse_of
https://qiita.com/itp926/items/9cac175d3b35945b8f7e
https://techtechmedia.com/inverse-of-rails/

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