おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

Active Recordで関連先を保存するときに気をつけること

先日、仕事で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_savehas_(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_userUser#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