先日、仕事でRails(Active Record)の難しい仕様に遭遇したので共有するためにエントリをしたためました。似たようなケースに遭遇した人の手助けになれば幸いです(\( ⁰⊖⁰)/)
対応Railsバージョンと設定
- Rails6.1以上
config.active_record.has_many_inversing = true
(Rails6.1のデフォルト設定)である
問題1
まず次のコードを読んでみてください。
class User < ApplicationRecord
has_many :posts
end
class Post < ApplicationRecord
belongs_to :user, inverse_of: :posts
before_update { puts 'before_update' }
end
user = User.new
post = Post.new(user:)
post.save
Post.new
してからsaveしているのでレコードは新規に作成されるはずなのですが、更新時のフックであるbefore_update
が実行されています。なぜでしょうか?
前提知識1
モデルの関連(has_many、has_one、belongs_to)にはそれぞれinverse_ofというオプションがあります。関連先のオブジェクトに関連元のオブジェクトをアサインするために使われる設定です。
例えばUserとAddressが1対1関連だったとして、次のコードを実行したとします。
address = user.address
address.user
このとき、address.user
が指すuserは明らかなのでクエリを発行したくはないですよね?そこでRailsは予めuser.address
とした時点で戻り値のAddressインスタンスに対してuserをアサインしてくれます。
inverse_ofは、関連先に関連元をアサインするときの関連名を指定するためのオプションです。inverse_ofを明示的に設定しなくても、関連名を推測できるときは自動でアサインします。
余談ですが、執筆時点では自動でinverse_ofを類推する機能が複数形に対応していないため、問題1の(1)でinverse_ofを有効にするためには明示的なinverse_ofの指定が必要です。 過去に複数形対応のPRがマージされたのですがデグレがあり後にrevertされた、という状況です。だれかがコントリビュートしたら将来のRailsでは明示的なinverse_ofは不要になるかもしれません。
上記「対応Railsバージョンと設定」にあるconfig.active_record.has_many_inversing = true
は、belongs_toを宣言しているモデルにhas_manyを宣言しているモデルをアサインしたときやbuild_associationメソッドで関連先を生成したときに、関連先に対してinverse_ofによるモデルのアサインをするようにする設定です(Rails 6.1まではbelongs_to :user, inverse_of: :postsのようにinverse_ofを明示的に宣言していても意味がありませんでした)。
この設定が有効なとき、問題1の(2)のタイミングでuser.postsが[post]
を返すようになります。
前提知識2
関連を定義すると、モデルをsaveする時のhookで関連先のsaveが実行される事があります。関連メソッドにautosave: true
オプションを付けていなければ、関連先のモデルが存在しているかつ関連先が永続化がされていない(new_record? #=> true
)ことがsaveされる条件です。
belongs_to
関連ならbefore_save
、 has_(one|many)
ならafter_(create|update)
のタイミングで関連先のsaveが実行されます*1。
関連先のモデルにも関連が定義されている場合、関連先のsaveは連鎖的に実行されていきます。すると関連の定義の仕方によっては永久にsaveの連鎖が実行されそうですが、そうはなりません。起点となるsaveメソッドの中で、同じhookメソッドを2回以上呼ぶとその処理は無視する仕組みが入っています*2。
問題1の解答 - なぜ新規保存なのにbefore_updateが実行されるのか
ようやく前提は終わりです。長かったですね。それでは実際に問題1のコードを実行したときに、どのような順番で関連先の保存が実行されるか見ていきましょう。
以下は ログ出力用に用意したスクリプト を利用して、saveと関連先の保存用メソッド、コールバックなどを時系列に出力したものです。メソッドの依存関係はインデントで表しています。
Post#save start
Post#before_save start
Post#autosave_associated_records_for_user start
User#save start
User#before_save start
User#before_save end
TRANSACTION (0.1ms) begin transaction
User Create (0.7ms) INSERT INTO "users" ("created_at", "updated_at") VALUES (?, ?) [["created_at", "2023-08-07 01:58:17.018474"], ["updated_at", "2023-08-07 01:58:17.018474"]]
User#after_create start
User#autosave_associated_records_for_posts start
Post#save start
Post#before_save start
Post#autosave_associated_records_for_user start
Post#autosave_associated_records_for_user end
Post#before_save end
Post Create (0.2ms) INSERT INTO "posts" ("user_id", "created_at", "updated_at") VALUES (?, ?, ?) [["user_id", 8], ["created_at", "2023-08-07 01:58:17.022856"], ["updated_at", "2023-08-07 01:58:17.022856"]]
Post#after_create start
Post#after_create end
Post#save end
User#autosave_associated_records_for_posts end
User#after_create end
User#save end
Post#autosave_associated_records_for_user end
Post#before_save end
before_update
Post#after_update start
Post#after_update end
Post#save end
Post#autosave_associated_records_for_user
やUser#autosave_associated_records_for_posts
が関連を定義したときに自動で生成される、関連先を保存するためのメソッド名です。
このログを注意深く見ると、最初に実行したPostのbefore_saveの中でPostのsaveが実行されてしまっているのがわかるのではないでしょうか。postにはuserが関連先として紐付けられていますし、userにはinverse_ofによりpostが紐づいている、という状況なのでこのように連鎖的にsaveが実行されることになります。
Postのbefore_saveの中でPost#saveが実行された後に、before_saveの続きが実行されます。このときすでにpostは永続化されているのでこのsaveはupdateと同様の操作になり、結果としてbefore_updateコールバックが実行されるという流れになります(before_updateはbefore_saveより後に呼ばれます)。このあとの処理ではモデルの属性に変化がないのでクエリは発行されません。
問題2
すでになるほどわからん、となっていそうですがまだ話は終わりません。次のコードを見てください。
class Comment < ApplicationRecord
belongs_to :user, inverse_of: :comments
belongs_to :post, inverse_of: :comments
end
class User < ApplicationRecord
has_many :posts
has_many :comments
end
class Post < ApplicationRecord
has_many :comments
belongs_to :user, inverse_of: :posts
end
user = User.new
post = Post.new(user:)
comment = Comment.new(user:, post:)
comment.save
一見なんの問題もないように見えますがsaveに失敗します。理由はエラーメッセージを見る限り`comments.user_id
がnullでNOT NULL制約に引っかかってしまったからのようです。なぜこのような事が起こるのでしょうか?
問題2の解答 - なぜNOT NULL制約になるのか
これも同じように ログ出力用に用意したスクリプトを利用してログ出力してみます。
Comment#save start
Comment#before_save start
Comment#autosave_associated_records_for_user start
User#save start
User#before_save start
User#before_save end
TRANSACTION (0.2ms) begin transaction
User Create (0.8ms) INSERT INTO "users" ("created_at", "updated_at") VALUES (?, ?) [["created_at", "2023-08-07 02:12:35.739117"], ["updated_at", "2023-08-07 02:12:35.739117"]]
User#after_create start
User#autosave_associated_records_for_posts start
Post#save start
Post#before_save start
Post#autosave_associated_records_for_user start
Post#autosave_associated_records_for_user end
Post#before_save end
Post Create (0.2ms) INSERT INTO "posts" ("user_id", "created_at", "updated_at") VALUES (?, ?, ?) [["user_id", 1], ["created_at", "2023-08-07 02:12:35.744300"], ["updated_at", "2023-08-07 02:12:35.744300"]]
Post#after_create start
Post#autosave_associated_records_for_comments start
Comment#save start
Comment#before_save start
Comment#autosave_associated_records_for_user start
Comment#autosave_associated_records_for_user end
Comment#autosave_associated_records_for_post start
Comment#autosave_associated_records_for_post end
Comment#before_save end
Comment Create (0.9ms) INSERT INTO "comments" ("user_id", "post_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["user_id", nil], ["post_id", 1], ["created_at", "2023-08-07 02:12:35.746900"], ["updated_at", "2023-08-07 02:12:35.746900"]]
Comment#save end
Post#autosave_associated_records_for_comments end
Post#save end
User#autosave_associated_records_for_posts end
User#save end # (a)
Comment#autosave_associated_records_for_user end # (b)
Comment#save end
TRANSACTION (0.6ms) rollback transaction
/Users/willnet/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sqlite3-1.6.3-arm64-darwin/lib/sqlite3/statement.rb:108:in `step': SQLite3::ConstraintException: NOT NULL constraint failed: comments.user_id (ActiveRecord::NotNullViolation)
/Users/willnet/.rbenv/versions/3.2.2/lib/ruby/gems/3.2.0/gems/sqlite3-1.6.3-arm64-darwin/lib/sqlite3/statement.rb:108:in `step': NOT NULL constraint failed: comments.user_id (SQLite3::ConstraintException)
問題1のときと同じように、Commentのbefore_save中にComment#saveが実行されているのが見て取れます。エラーになっているのはこれが原因です。
関連先のsaveが終わらないと関連先の主キーが確定しないので、belongs_to関連の外部キーが設定されるタイミングは関連先のsaveが終わったタイミングになります。has_many関連のときは、関連先のsaveを実行する前に外部キーを設定します。
問題2のコードでは、関連先(User)の主キーをCommentの外部キーとして設定するのはUser#save
が終わった後のComment#autosave_associated_records_for_user
の処理中(上記ログの(a)から(b)の間)です。
ですがこのコードでは、Comment->User->Post->Commentのように関連先の保存の連鎖が起きた結果、外部キーを設定するより前にINSERTクエリを発行しているのでNOT NULL制約に違反してエラーとなっています。Comment#saveが終わってからエラーの表示が出力されているので分かりづらいですが、実際にはComment Create (0.9ms) INSERT INTO ...
のところでNOT NULL制約でエラーになっています。
問題の解決策
テストで利用するデータの作成にFactoryBotを使い、buildストラテジを利用してモデルと関連先を作成してからsaveすると今回のサンプルコードのようなケースになりやすい印象です。
複数の関連を一度のsaveで連鎖的に保存しようとしない限りは問題は起きないはず。もし遭遇した場合はもっと細かい単位でモデルをsaveするようにコードを修正すると良いでしょう。