私は現在、未経験からのエンジニア転職に向けてプログラミングスクールで学習をしている、いしかわと申します。
個人開発中にrubocop
からRails/InverseOf
と警告されたことをきっかけにActive Record
のinverse_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/InverseOf
とrubocop
に警告されました
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さんが行っていたようにuser1
とuser2
を特定し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/