失敗したテストのログだけを出力するぞ - おもしろwebサービス開発日記で
現時点(v0.3.0)ではRSpecのみに対応しています。minitestで使いたい人はPRお願いします :pray:
と書きましたがv0.6.0ではminitest対応を入れました。minitest派の方もどうぞご利用ください。
失敗したテストのログだけを出力するぞ - おもしろwebサービス開発日記で
現時点(v0.3.0)ではRSpecのみに対応しています。minitestで使いたい人はPRお願いします :pray:
と書きましたがv0.6.0ではminitest対応を入れました。minitest派の方もどうぞご利用ください。
先日、仕事でRails(Active Record)の難しい仕様に遭遇したので共有するためにエントリをしたためました。似たようなケースに遭遇した人の手助けになれば幸いです(\( ⁰⊖⁰)/)
config.active_record.has_many_inversing = true
(Rails6.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
が実行されています。なぜでしょうか?
モデルの関連(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]
を返すようになります。
関連を定義すると、モデルを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のコードを実行したときに、どのような順番で関連先の保存が実行されるか見ていきましょう。
以下は ログ出力用に用意したスクリプト を利用して、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より後に呼ばれます)。このあとの処理ではモデルの属性に変化がないのでクエリは発行されません。
すでになるほどわからん、となっていそうですがまだ話は終わりません。次のコードを見てください。
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制約に引っかかってしまったからのようです。なぜこのような事が起こるのでしょうか?
これも同じように ログ出力用に用意したスクリプトを利用してログ出力してみます。
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
TokyuRuby会議14に参加してLTしてきました。
スライドはこちら。
mrskは個人的にかなり推しているプロダクトで、これの存在により仕事でも個人でも今より安いインフラを活用する選択肢を取りやすくなったと感じています。とりあえず僕が今運用しているサービスはmrskを活用してDigitalOceanあたりに寄せていけたらな、と思います。みなさんも使ってみると便利ですよ。オススメ。
久しぶりにオフラインで5分のLTをしました。あと色んな人とビールを飲みながら雑に会話して、コロナ禍以前の地域コミュニティの感覚ってこんなだったなあ…と思っていたら6時間があっという間に過ぎていきましたね。次回のTokyuRuby会議も楽しみにしています。
Osaka RubyKaigi 03のCFPが通っているので、9月の発表に向けて資料作りを頑張っていきます。当日大阪参加する人はよろしくお願いします(\( ⁰⊖⁰)/)
この件、関連するPRやIssueが複数あってコメントも分散しており、人に説明するのがややこしいのでブログとしてまとめたものになります。間違いや意見などあったらコメントください!
7-0-stableブランチに今回の変更をrevertするコミットが入りました。挙動が変わって困った人が出たので一度元に戻して仕切り直しにする方針のようです。
7.0.7がいつリリースされるかはわかりませんが、今の状態でリリースされたら7.0.4の振る舞いに戻ることになります。7.0.5以降の変更で困っている人は一旦7-0-stableを指すようにすると良いかもしれません。
:nullify
、もしくはdependentオプションを設定していないときは外部キーをNULLにしてupdate:destory
のときはdestroyメソッドで削除:delete
のときはdeleteメソッドで削除例えば次のコードでの(1)~(3)の全てで、既存の関連先に紐づくレコードがあればそれを削除します。
class User < ApplicationRecord has_one :address, dependent: :destroy end class Address < ApplicationRecord belongs_to :user end user = User.find(42) user.address = Address.new # (1) user.create_address # (2) user.build_address # (3)
これまで、create_associationの挙動は Improve singular association creation by lazaronixon · Pull Request #46386 · rails/rails で説明があるように別々のトランザクションでinsertしてからdeleteするという挙動でした。
# begin transaction INSERT new_record; # commit transaction # begin transaction DELETE old_record; # commit transaction
さらに、先行のinsert(saveメソッド経由)がバリデーションエラーなどで失敗しても、後続のdeleteは実行されます。この挙動により、既存のレコードがある状態でcreate_association!をした際にバリデーションエラーになっても既存のレコードが削除される、という不具合がありました。
他にも、次のような流れで既存レコードの削除が効かない、という問題もありました。
関連Issue: Sometimes create_association
does not delete existing records · Issue #47554 · rails/rails
7.0.5では次のように同一トランザクションでdeleteしてからinsertする、という挙動に変わりました。上記の不具合はほぼ解消*3されました。
# begin transaction DELETE old_record; INSERT new_record; # commit transaction
しかし、もともとhas_oneの外部キーに対してユニーク制約をかけており、既存のレコードがあるときにcreate_associationするとバリデーションエラーとしたいアプリケーションにとってはコードの修正が必要になります。
7.0.5における変更は「前提」の節で説明した挙動の整合性を取るために必要な修正であったと個人的に感じていますが、一方で「association=
やbuild_association
やcreate_association
をしたら既存のレコードが削除される」というのは知らない人にとってはびっくりする挙動とも思うので、今回の変更でコードの修正を強いられてつらい、という人の気持ちもわかります。
これ、そもそもどのような挙動をするとみんなが幸せになれるんでしょうね…???
*1:association=がその挙動をするのは書かれている http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_one けれど、build_associationやcreate_associationはRailsガイド含めて書かれていなさそう
*2: コード的にはこのあたりが該当します。 https://github.com/rails/rails/blob/89508b95d8d289b430cca7db05109d8171ff7a5f/activerecord/lib/active_record/associations/has_one_association.rb#L94-L116
*3:!なしのcreate_associationでバリデーションエラーになったときは変わらず既存のレコードがDELETEされるのですが、これはbuild_associationしてsaveしても一緒なのでそういう仕様と言えなくもない
久しぶりに新しいPRがきたので、確認ついでにGitHub Actionsなどの設定も見直して1.2.0としてリリースしました。やっぱり長く運用しているリポジトリだと定期的に見直しが必要ですね…。
2月21日は弊社の設立記念日です。6周年あっという間ですね。
今日は弊社の設立記念日なのでケーキでも食べたいな、と思ってコンビニに来たがいい感じのものがなかった。来年はケーキ屋で予約でもしようかなあ
— willnet (@netwillnet) February 21, 2023
おおまかな仕事の仕方は設立2周年のときから特に大きく変わってはいません。が4年間でお手伝い先も子供も増えて、コロナ禍で通勤がなくなったにもかかわらずめっちゃ忙しくしております。
それでも多少子育てに余裕が出てきた気がするので、今期はメモブログも合わせて発信をもう少し増やしていきたいな〜、と思っています。関係各位今後とも宜しくお願いします(\( ⁰⊖⁰)/)
10年ほど前にサービス開始したmiucheeですが、この度Twitter API有料化を受けてサービス終了することにしました。
10年前はぼくの両親の携帯電話はガラケーでしたが、今となっては二人ともスマホを使うようになり、みてね 経由で孫の写真を共有したり、facetimeでビデオ通話をしたりとするようになりました。
昔と比べるとスマホを始めとするデバイスがいろんな世代にも普及して、情報共有がやりやすくなった結果miucheeもその役目を終えたのではと思います。
ご利用いただいていたユーザの皆様ありがとうございました。