- Ruby / Rails関連
週刊Railsウォッチ: Active Recordにstrict_loading_mode追加、to_time_preserves_timezoneの扱いほか(20240625前編)
こんにちは、hachi8833です。
🔗Rails: 先週の改修(Rails公式ニュースより)
- Ruby on Rails — Global strict loading mode setting, route draw deferring and more
- Ruby on Rails — New transaction event, bugfixes and more!
🔗 strict_loading_mode
クラス属性を追加
strict_loading_mode
をモデル内とグローバルのどちらにも設定可能になったデフォルトは
:all
だが、:n_plus_one_only
に変更可能。Garen Torikian
同Changelogより
Railsのstrict loadingの概要記事を読んでいて以下のパラグラフがあった。
勇気があれば、アプリケーション全体をstrict loadingすることは一応可能(奇妙なことに
n_plus_one_only
オプションに相当するものが見当たらないので、これを使うのは想像できない)。自分のアプリでは、
strict_loading!(mode: :n_plus_one_only)
をレコードごとに設定しているが、これを読んでその必要がないことに気づいた。このプルリクを作成した理由は、
strict_loading_mode
が常に:all
に設定されているため。ユーザーによっては、:n_plus_one_only
をモデルごとに設定するのが好ましいこともあれば、アプリ全体で使うのが好ましいこともある。詳細
このプルリクは、
:strict_loading_mode
という新しいclass_attributeを追加する(デフォルトは:all
)。:n_plus_one_only
に設定すると、strict loadingチェックを行うときはデフォルトでこのモードが使われる。同PRより
つっつきボイス:「eager loadingせずに関連付けにアクセスするとStrictLoadingViolationError
を発生するstrict_loading
は、たしかRails 6.1あたりで導入されていましたね(ウォッチ20200302): 今回の改修でstrict_loading_mode
というクラス属性(デフォルトは:all
)を追加したことで、strict loadingエラーを常に出すか、:n_plus_one_only
でN+1が発生する可能性がある場合にのみエラーを出すかを選べるようになった、こういうこともできた方が嬉しいでしょうね👍」「新しいアプリならデフォルトの:all
にしておきたいところですね😋」
参考: 14.5 strict_loading
-- Active Record クエリインターフェイス - Railsガイド
リレーションで
strict_loading
モードを有効にすると、レコードが任意の関連付けを遅延読み込みしようとしたときにActiveRecord::StrictLoadingViolationError
が発生します。user = User.strict_loading.first user.address.city # ActiveRecord::StrictLoadingViolationErrorが発生 user.comments.to_a # ActiveRecord::StrictLoadingViolationErrorが発生
14.5
strict_loading
-- Active Record クエリインターフェイス - Railsガイドより
Rails 7.2: strict_loadingがn_plus_one_onlyモードで子の関連付けをeager loadingしないよう修正(翻訳)
🔗 developer/test環境でルーティング生成を遅延実行して起動を高速化
- ルーティングの生成を最初のリクエスト時または
url_helpers
呼び出し時まで遅延実行するミドルウェアで最初のルーティングの再読み込みを実行するか、ルーティングセットの
url_helpers
がルーティング呼び出しを受け取ったとき、またはルーティングに応答するかどうかをチェックされたときに実行する。
従来は起動時に無条件に実行されていたため、ルーティングが多い大規模アプリでは起動時間が不必要に遅くなることがあった。Gannon McGibbon
同Changelogより
リトライ: #51614
Closes: #51906
動機/背景
このプルリクを作成した理由は、ルーティングが多数あるアプリの起動に時間がかかるため。開発者はルーティングと無関係な理由(単体テスト、マイグレーション、rakeタスク実行など)でアプリを起動することもあるので、development環境とtest環境では遅延実行(defer)する必要があると思う。
詳細
このプルリクは、エンジンとアプリのルーティングセットを、現在のRailsアプリケーションを認識する
Rails::Engine::RouteSet
に変更する。デフォルトのミドルウェアスタックも変更し、必要に応じてルーティングを読み込むRails::Rack::LoadRoutes
ミドルウェアも追加した。このプルリクによって、以下の場合にルーティングを読み込むようになる。development環境とtest環境の場合:
- ミドルウェア経由で最初のリクエストがあったとき
- アプリケーションまたはエンジンの
url_helpers.some_path
がmethod_missing?
経由で呼び出されたとき- アプリケーションまたはエンジンの
url_helpers.respond_to?(:some_path)
がrespond_to_missing?
経由で呼び出されたときproduction環境の場合:
- finisherでeager loadingする(従来の振る舞い)
開発者が何らかの理由で以前の振る舞いに戻したい場合は、イニシャライザで
Rails.application.reload_routes!
を利用できる。ただしより安全にしたい場合は、この振る舞いを構成変数に隠蔽することも可能。
同PRより
つっつきボイス:「大規模Railsアプリのルーティングテーブルが巨大になると起動が遅くなるのはあるあるですね: たしかにdeveloper環境やtest環境のように頻繁に起動するときなんかはルーティング生成は遅延実行していいと思います👍」「マイグレーションやランナー実行なんかも遅延実行したい」「逆にproduction環境ならルーティングの事前生成を全部終えてからロードバランサーにサービスインさせたいですね」「なるほど」
「なお、上のプルリクは最初は以下の#51614だったんですが↓、ルーティングテーブル生成を遅延実行したらDeviseのテストヘルパーがエラーになったという報告があったので#52012で再挑戦したそうです」「あ〜そんなことがあったんですね: Deviseはいろいろ込み入っていますし、評価順序が変わるとルーティングのテストコードが壊れるというのはありそう」
参考: Defer route drawing to the first request, or when url_helpers called by gmcgibbon · Pull Request #51614 · rails/rails
参考: Make test helpers work with Rails 8 deferred routes by jeromedalbert · Pull Request #5695 · heartcombo/devise
🔗 logger gemを明示的にRailsの依存関係に追加
これは
base64
やmutex_m
などと同じ扱いになる。Ruby 3.4ではruby/ruby@d7e558e
によって警告が表示されるようになる。他の例については#48907を参照。
また、不要と思われる2つの
require
も削除した。テストは::Logger
にアクセスしないが、他は直後にas/logger
をrequire
する。
同PRより
つっつきボイス:「Rubyのlogger gemが"bundled gem"扱いに変わるのでデフォルトで依存関係に追加したそうです」「logger gemはこれまでdefault gemだったみたいだけど、bundled gemに変わるのか」「default gemはgem
コマンドで削除できなくて、bundled gemは削除可能なんですよね」「bundled gemになればrequire
しなくてもよくなる👍」
参考: standard librariesとdefault gemsとbundled gemsの違い - ESM アジャイル事業部 開発者ブログ
「あれ?Rubyのコミット(d7e558e)を見るとRuby 3.4じゃなくてRuby 3.5になってる」「ホントだ、Ruby 3.4から警告を表示してRuby 3.5でbundled gemに変わるということみたいですね」
🔗 to_time_preserves_timezone
の非推奨化を仕切り直し(進行中)
- PR: Re-roll deprecation of to_time_preserves_timezone by matthewd · Pull Request #51994 · rails/rails
前回の非推奨警告が一部のユーザーに表示されていなかったため、このまま削除を進めると、警告されていない振る舞いが変更されてしまう可能性があることが判明した。
このプルリクは、前回の非推奨を復元して、
to_time
が初めて呼び出されたときに1回限りで警告を追加する(コンフィグが未設定の場合)。この1回限りの警告は、呼び出しのたびに警告する通常の戦略から逸脱することになるが、この場合は適している。
to_time
呼び出しのたびに警告を表示すると、繰り返しが多すぎる- その場で表示されるアクション自体が1回限りのものである
アプリケーションでは、最終的には各呼び出しサイトを確認して、戻り値が変更されてもよいかどうかを確認する必要があるかもしれないが、測定可能な唯一のアクションはコンフィグをグローバルに切り替えることである。呼び出しのたびに警告しても、呼び出しAPIの変更の場合のような網羅的なリストは提供されない。
同PRより
つっつきボイス:「to_time_preserves_timezone
は古いRubyの下位互換のためにRails 5で入った設定で(ウォッチ20220328)、本来はそれを非推奨化して削除するという流れだったようです」「その非推奨化の警告表示範囲が不十分だったので非推奨化を延長というかやり直すことになったんですね」
参考: 8.20.8 レシーバのタイムゾーンを保護する -- Rails アップグレードガイド - Railsガイド
Ruby 2.4を利用している場合、
to_time
の呼び出しでレシーバのタイムゾーンを変更しないようにできます。ActiveSupport.to_time_preserves_timezone = false
「RailsでのRuby 2.x系のサポートはとっくに終了しているので(Rails 7.2ではRuby 3.1以降をサポート)、Ruby 2.4より前の機能をサポートするto_time_preserves_timezone
はもう不要という流れでしょうね」
🔗 追記: Rails 7.2.0のマイルストーンとの関連
#51994はto_time_preserves_timezone
を最終的に削除する前提のようですが、Rails 7.2.0のマイルストーンにある2つのプルリク#52091と#52031↓は少々異なっているようなので調べてみました。
参考: Add a config for preserving timezone information when calling to_time
on TimeWithZone object by jasonkim · Pull Request #52091 · rails/rails -- 現在オープン
参考: Don't emit to_time deprecations in known-safe contexts by matthewd · Pull Request #52031 · rails/rails -- 現在オープン
#52091は警告は表示しつつto_time_preserves_timezone
は削除しない前提、#52031は#51994を参照しつつ警告の表示範囲を絞り込む、というように足並みがまだ揃っていないようです。
これは想像ですが、現時点でRails 7.2.0最終版がまだリリースされていないのは、これら3つのプルリクの落とし所を策定中だからかもしれません🤔。
動機/背景
このプルリクを作成した理由は、Active SupportのTimeWithZoneオブジェクトの
to_time
がタイムゾーン情報を保持していないため。このプルリクは、TimeWithZoneオブジェクトでto_time
を呼び出したときにタイムゾーン情報を保持するコンフィグを追加する。詳細
このプルリクは、
ActiveSupport.to_time_preserves_timezone
コンフィグに新しく:zone
値を追加する。さらにActiveSupport.to_time_preserves_timezone
の既存のtrue
値を:offset
に変更する(これはUTCオフセットを利用する従来の振る舞いを維持する)。
:zone
に設定すると、to_time
はTimeWithZoneオブジェクトと同じタイムゾーンを持つTimeオブジェクトを返す。
これにより、Rails 8.0のActiveSupport.to_time_preserves_timezone
のデフォルト値は:zone
になる。
#52091より
Active Recordテストの実行中、#51994によって非推奨警告が表示されていることに気づいた(これによってCIが失敗したと思っていたが、おそらく自分の記憶違いのはず)。
新しい設定を自分たちのテストスイートでオプトインすることも一応可能だが、同じような呼び出し元(つまりタイムゾーンにことさら関心を持たない呼び出し元)がこの変更を認識せずに済む(そして対応せずに済む)ようにする方法が可能かどうかを見ておきたい。
Time#to_time
は値が既にローカル時刻であれば振る舞いに違いは生じないので、ほとんどの場合警告表示は不要。自分は警告表示に応じて、内部で使われていたいくつかの場所で
to_time
を呼び出さないように変えた。この作業は簡単で、タイムゾーンを気にする必要がないことはわかっていたので、これも変更による影響は生じない。実際にこの問題の影響を受ける(少なくとも本当に影響を受けるリスクがありうる)人たちがこの非推奨化警告に気づく可能性が高まり、それによってこの混乱を正当化できることを願っている。
ここで注目して欲しいのは、自分は
ActiveSupport::TimeWithZone#to_time
をまだ変更していないということだ。この警告は、タイムゾーンがローカルと一致する場合(両者がUTCである場合が最も一般的)でも表示される。当時はこの方法が正しいと思えたが、現在はあまり自信がない...
3.days.ago.to_time
はかなり合理的な普通の書き方に思えるし、(ゾーン関連のコンフィグがなければ)完璧に安全だと信じている 🤔。
#52031より
🔗 ids_reader
が複合主キーで期待通りの結果を返さない問題を修正
- 複合主キーを使うモデルでプリロードされた関連付けに対して、IDリーダーメソッドが期待通りの結果を返さない問題を修正
Jay Ang
同Changelogより
動機/背景
#51129を修正するため。詳細
モデルで複合主キーを使うと、
primary_key
は配列になる。これにより、プリロードされた関連付けで<関連付け名>_ids
メソッドを呼び出すときに問題が発生する。内部的には、Railsはプリロードされた結果をEnumerableのpluck
メソッドで取得するが、pluck
メソッドの引数には配列を渡せない。これに対応するには、splat演算子*
で配列を複数の引数に動的に分割する必要がある。
同PRより
つっつきボイス:「primary_key
という名前は単数形だけど、複合主キーが使われると同じ名前のまま配列を返すようになるのがややこしい」「かといって今さらprimary_keys
みたいに複数形にするわけにもいかなさそうですね」「primary_key
はほとんどの場合単一のキーしか持たないし、複数形にすると他のgemなども影響を受けることになって変更範囲が広がりすぎてしまうので、primary_key
が配列も返すのはある程度仕方ないでしょうね」
「修正はsplatの*
を追加しただけなんですね↓」
# activerecord/lib/active_record/associations/collection_association.rb#L51
def ids_reader
if loaded?
- target.pluck(reflection.association_primary_key)
+ target.pluck(*reflection.association_primary_key)
elsif !target.empty?
- load_target.pluck(reflection.association_primary_key)
+ load_target.pluck(*reflection.association_primary_key)
else
- @association_ids ||= scope.pluck(reflection.association_primary_key)
+ @association_ids ||= scope.pluck(*reflection.association_primary_key)
end
end
🔗 Action Textでcontent
属性の不要なサニタイズを削減
- 添付ファイルが存在する場合にのみ
content
属性をサニタイズするよう修正Petrik de Heus
同Changelogより
Action Textの添付ファイルで
content
属性が設定済みの場合、Trixはこのコンテンツを表示する。
1ac6d40
によって、ActionText::Attachable::ContentAttachment
のcontent
属性のサニタイズが導入された。
ただしこれはcontent
属性が存在しない場合にも設定されてしまう。そのため、Trixは空のcontent
属性を用いることになり、画像プレビューが表示されなくなる。Basecampリポジトリのbasecamp/trix#1158で報告された。
追加情報
1ac6d40
で追加されたテストは、元のサニタイズ修正をコメントアウトしても失敗しなかったので、これを修正するためのテストも追加した。
同PRより
つっつきボイス:「これはAction Textの修正」「添付ファイルがある場合だけcontent
をサニタイズするという条件文を追加するシンプルな修正ですね↓」
# actiontext/lib/action_text/content.rb#L98
def render_attachments(**options, &block)
content = fragment.replace(ActionText::Attachment.tag_name) do |node|
- node["content"] = sanitize_content_attachment(node["content"])
+ if node.key? "content"
+ node["content"] = sanitize_content_attachment(node["content"])
+ end
block.call(attachment_for_node(node, **options))
end
self.class.new(content, canonicalize: false)
end
🔗 app:update
コマンドでpuma.rb
を常に上書きするよう修正
動機/背景
これは#41083を部分的に取り消す。
puma.rb
はユーザーによって更新される可能性があるが、Railsはたまにpuma.rb
を改善することがある(例:06d614a
やf719787
)。ユーザーがこうした改善を認識できるように、
app:update
コマンドでpuma.rb
を更新すべきだと思う。
同PRより
つっつきボイス:「app:update
コマンドはこの間も回収されていましたね(ウォッチ20240619)」「Rails 7.2でpuma.configのスレッド数が変更されたりしているので(ウォッチ20240206)、rails app:update
コマンドを実行したときにpuma.configが新しくなったことに気づけるようにした、なるほど」「アプリはGitなどでバージョン管理しているはずなので、差分が出れば気づけるはずという前提ですね」
# railties/lib/rails/generators/rails/app/app_generator.rb#L123
def config
empty_directory "config"
inside "config" do
template "routes.rb" unless options[:update]
template "application.rb"
template "environment.rb"
template "cable.yml" unless options[:update] || options[:skip_action_cable]
- template "puma.rb" unless options[:update]
+ template "puma.rb"
template "storage.yml" unless options[:update] || skip_active_storage?
directory "environments"
directory "initializers"
directory "locales" unless options[:update]
end
end
🔗 start_transaction.active_record
イベントを追加
start_transaction.active_record
というイベントを新たに定義する。これは、データベーストランザクションやsaveポイントが開始するときに発火する。これは、終了時にトリガーされるtransaction.active_record
を補完する。さらに詳しい情報やサンプルは、このパッチに含まれている。
命名規則は既存の同様のイベントで確認した。1つは
start_processing.action_controller
で、もう1つはperform_start.active_job
。自分はstart_transaction.active_record
の方が自然だと思う。
7-2-stable
にバックポートしてChangelogエントリを追加する予定。
同PRより
参考: 週刊Railsウォッチ20240619: Instrumentationのsql.active_record
とtransaction.active_record
に現在のトランザクションを追加
つっつきボイス:「transaction.active_record
イベントの追加(ウォッチ20240619)に続く改修ですね」「transaction.active_record
はトランザクションが完了したときに発火するイベントで、それと対になるトランザクション開始で発火するイベントを追加したんですね👍」「start_transaction.active_record
イベントは以下の値を取れるそうです↓」「Instrumentationはドキュメントであまり網羅されていない傾向があったので、こうやってドキュメントも更新されるとありがたい」
| Key | Value |
| -------------------- | ---------------------------------------------------- |
| `:transaction` | Transaction object |
| `:connection` | Connection object |
🔗 Active Recordのin_order_of
にfilter
オプションを追加
- PR: [ActiveRecord] Add option
filter
onin_order_of
by igordepolli · Pull Request #51761 · rails/rails
filter
オプションにin_order_of
を追加
これは結果をフィルタせずに、ソートによって特定の値を優先する。Igor Depolli
同Changelogより
動機/背景
このプルリクを作成した理由は、現在の
in_order_of
メソッドが常にwhere
句を用いて、values
で指定した値だけで結果をフィルタリングしているため。
場合によっては、特定の値の優先順位だけをソートで高くして、残りのソート結果を気にせずに検索範囲全体を必要とすることもある。ここでは、範囲を値でフィルタするかどうかを指定するオプションの追加を提案したい。詳細
このプルリクは以下を変更する。
order = [3, 4, 1] # オプションを指定しない場合 Post.in_order_of(:id, order).to_sql # SELECT # "posts".* FROM "posts" # WHERE # "posts"."id" IN (3, 4, 1) # ORDER BY # CASE WHEN "posts"."id" = 3 THEN 1 WHEN "posts"."id" = 4 THEN 2 WHEN "posts"."id" = 1 THEN 3 END ASC # オプションをfalseに設定した場合 Post.in_order_of(:id, order, filter: false).to_sql # SELECT # "posts".* FROM "posts" # ORDER BY # CASE WHEN "posts"."id" = 3 THEN 1 WHEN "posts"."id" = 4 THEN 2 WHEN "posts"."id" = 1 THEN 3 ELSE 4 END ASC
同PRより
つっつきボイス:「現行のin_order_of
↓はWHERE
を使う形になっていて実質的に絞り込みの機能も兼ねていたので、従来だとソート用項目にないものは結果に出てこなかったけど、改修後はfilter: false
オプションを指定するとWHERE
を使わない形になって、改修前にはフィルタされていて選択されなかったカラムもorderingの末尾に出るようになったんですね: ソート方法の選択肢が増えるのはありがたい👍」
Rails 7: クエリ結果を任意の順序にできるActiveRecord::QueryMethods#in_order_of
🔗 bin/rails notes
の結果をブラウザでも表示可能になった
動機/背景
bin/rails notes
を早めにチェックする方法が欲しい。
rails/info/routes
のルーティング表示に触発されたので、rails/info/notes
という内部ルーティングを提案したい。これは以下と同じ。
$ bin/rails notes app/controllers/posts_controller.rb: * [ 9] [TODO] Move this logic to a concern * [18] [FIXME] Refactor this method app/models/post.rb: * [ 2] [TODO] Refactor this validation
詳細
bin/rails notes
の内部ルーティングを追加することで、notesをブラウザUIでチェックできるようになる。追加情報
オプション1:
オプション2:
同PRより
つっつきボイス:「bin/rails notes
って何だっけと思ったら、TODOとかFIXMEみたいなメモを検索して場所を表示してくれる機能でしたね」「それをアプリのrails/info/notes
というパスで表示できるようにしたんですね: ちなみにJetBrains IDEなどには独自のTODOコメント抽出機能があるので、Railsとは無関係にIDEの機能として一覧できたりします」
参考: 2.10 bin/rails notes
-- コマンドラインツール - Railsガイド
参考: TODO comments | IntelliJ IDEA Documentation
前編は以上です。
バックナンバー(2024年度第2四半期)
- 20240619前編 Rails 8からPropshaftがアセットパイプラインのデフォルトにほか
- 20240529 Rails 8でKamalがデフォルトのデプロイツールになるほか
- 20240514後編 Ruby/Railsのアップグレード情報をscrapboxに集約ほか
- 20240513前編 Railsコンソールが最新のIRB APIに移行、assertionless_tests_behaviorほか
- 20240426後編 Prismの歴史と現況を振り返る、Steepの"narrowing"実装の内部ドキュメントほか
- 20240425前編 RailsからOpenStructを削除、Playwrightベストプラクティスほか
- 20240423後編 Kamalはゲームチェンジャーになるか、Solid Queueで使われているfugitほか
- 20240416前編 ジョブのエンキューをトランザクション完了時まで自動先延ばしほか
- 20240410後編 SeleniumでRubyの全クラスとモジュールにRBSが追加ほか
- 20240409前編 Rails公式の"rails-new"ツールでRailsプロジェクトをセットアップほか
- 20240402 solid_queueとmission_control-jobsが正式にRailsのgemに、Rubyの"チルド"文字列ほか
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。
週刊Railsウォッチについて
TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)