Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

週刊Railsウォッチ: Active Recordにstrict_loading_mode追加、to_time_preserves_timezoneの扱いほか(20240625前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

  • 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やX.comでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙏

TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)

🔗Rails: 先週の改修(Rails公式ニュースより)

🔗 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_pathmethod_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の依存関係に追加

これはbase64mutex_mなどと同じ扱いになる。Ruby 3.4ではruby/ruby@d7e558eによって警告が表示されるようになる。

他の例については#48907を参照。

また、不要と思われる2つのrequireも削除した。テストは::Loggerにアクセスしないが、他は直後にas/loggerrequireする。
同PRより


つっつきボイス:「Rubyのlogger gemが"bundled gem"扱いに変わるのでデフォルトで依存関係に追加したそうです」「logger gemはこれまでdefault gemだったみたいだけど、bundled gemに変わるのか」「default gemはgemコマンドで削除できなくて、bundled gemは削除可能なんですよね」「bundled gemになればrequireしなくてもよくなる👍」

ruby/logger - GitHub

参考: 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の非推奨化を仕切り直し(進行中)

前回の非推奨警告が一部のユーザーに表示されていなかったため、このまま削除を進めると、警告されていない振る舞いが変更されてしまう可能性があることが判明した。

このプルリクは、前回の非推奨を復元して、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

8.20.8 レシーバのタイムゾーンを保護する -- Rails アップグレードガイド - Railsガイドより

「RailsでのRuby 2.x系のサポートはとっくに終了しているので(Rails 7.2ではRuby 3.1以降をサポート)、Ruby 2.4より前の機能をサポートするto_time_preserves_timezoneはもう不要という流れでしょうね」

🔗 追記: Rails 7.2.0のマイルストーンとの関連

#51994to_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::ContentAttachmentcontent属性のサニタイズが導入された。
ただしこれは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を改善することがある(例: 06d614af719787)。

ユーザーがこうした改善を認識できるように、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_recordtransaction.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_offilterオプションを追加

  • 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四半期)

週刊Railsウォッチ: Ruby on Jets 6.0がRailsをサポートほか(20240620後編)

ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。

Rails公式ニュース


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。