先日、仕事で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 # (1) before_update { puts 'before_update' } end user = User.new post = Post.new(user:) # (2) post.save # 'before_update'が出力される!
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 #=> NOT NULL constraint failed: comments.user_id (SQLite3::ConstraintException)
一見なんの問題もないように見えますが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するようにコードを修正すると良いでしょう。
*1:関連先が保存された後にafter_createやafter_updateを発火できるように、after_saveではなく、それよりも前に実行されるafter_createとafter_updateを利用しています
*2:この仕組みを実現するために、define_non_cyclic_method というメソッドが使われていますhttps://github.com/rails/rails/blob/db6159ce62b393d4422d4890f26b53c66db5c4f9/activerecord/lib/active_record/autosave_association.rb#L157-L174