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

週刊Railsウォッチ: 非推奨化済み機能をRails 7.2に向けて多数削除ほか(20231213前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 production環境でraiseせずにレポートするErrorReporter#unexpectedを追加

前提条件違反をレポートするErrorReported#unexpectedを追加。

例:

def edit
  if published?
    Rails.error.unexpected("[BUG] Attempting to edit a published article, that shouldn't be possible")
    return false
  end
  # ...
end

上のコードはdevelopment環境やtest環境ではエラーをraiseするが、production環境ではエラーのレポートだけを行う。

Jean Boussier
同Changelogより


つっつきボイス:「unexpectedはproduction環境ではエラーを出さずにレポートだけ出力するんですね」「エラーは開発段階のうちに検出したい」「似たようなコードを手書きすることは割りとあるので、Railsでサポートしてくれるのはよさそう👍」

# activesupport/lib/active_support/error_reporter.rb#L145
    def unexpected(error, severity: :warning, context: {}, source: DEFAULT_SOURCE)
      error = RuntimeError.new(error) if error.is_a?(String)
      error.set_backtrace(caller(1)) if error.backtrace.nil?

      if @debug_mode
        raise UnexpectedError, "#{error.class.name}: #{error.message}", error.backtrace, cause: error
      else
        report(error, handled: true, severity: severity, context: context, source: source)
      end
    end

🔗 with_routingテストヘルパーを統合テストで使えるようにした

ActionDispatch::IntegrationTestwith_routingテストヘルパーのサポートを追加する。
このヘルパーは、これまで統合テストのコンテキストで動かなかった(ActionController::TestCaseでしか使えなかった)。理由はテスト中にRackアプリと統合セッションが改変されなかったため。コントローラのテストはデフォルトで統合テストなので、この種のテストケースでもルーティングのテストをサポートすべき。

動機/背景

このプルリクを作成した理由は、統合テストでwith_routingを使う必要があったが動かないらしかったため。

詳細

このプルリクは、統合テストでのルーティングアサーションを変更してActionDispatch::Assertions::RoutingAssertions::WithIntegrationRoutingincludeし、標準のwith_routing機構をオーバーライドして、テスト中のアプリと統合セッションを改変するようにする。

追加情報

既存のルーティングアサーションテストについては共有テストモジュールを利用することで、コントローラのテストと統合テストを同じ方法で行えるようにした。
同PRより


つっつきボイス:「with_routingをこれまで統合テストで呼び出せなかったのを修正したのね」「with_routingというテストヘルパーがあったんですね」「ルーティングのテストを書く機会はそれほどないけど、必要ならば統合テストでも書くべきですね」

参考: Rails API with_routing -- ActionDispatch::Assertions::RoutingAssertions
参考: 統合テスト - Wikipedia

🔗 Active Storageでフォームの<button>要素内でのネストをサポート

動機/背景

この変更は、type="submit"buttonまたはinputに子要素(spanやアイコンなどのHTML要素)が含まれる場合に発生する可能性のある問題に対処するために必要となる。

現在のActive Storageでは、送信ボタンがクリックされたかどうかを判断するためにdidClickイベントリスナーがクリックイベントのtargetをチェックしている。イベントのtargetプロパティは、クリックされた特定のHTML要素を参照する。

送信ボタンに子要素が含まれており、さらに子要素の1つが実際にクリックされる要素である場合、targetはボタンそのものではなく、この子要素を参照する。

didClick関数がチェックするのは、type="submit"buttonまたはinputであるかどうかなので、このチェックは失敗し、submitButtonsByFormにボタンが保存されなくなる。

その結果、ダイレクトアップロードの後にフォームが送信されると、本来別のボタンがクリックされたとしても、フォーム内の最初の送信ボタンが誤ってフォーム送信に使われる可能性がある。複数の異なる送信ボタンがある場合、フォーム送信でそれぞれ異なるアクションをトリガーすることを目的としている可能性があるため、これは予期しない振る舞いとなる可能性がある。

詳細

クリックされたtype="submit"buttonまたはinput要素から最も近い先祖要素をElement.closest()メソッドで検索することで、クリックイベントがボタンの子要素によってトリガーされたときにもsubmitButtonsByFormに正しいボタンが保存されるようにした。これによって問題が解決し、ダイレクトアップロード後に正しいボタンでフォームを送信できるようになる。
同PRより


つっつきボイス:「Active Storageで使うJavaScriptコードの修正か」「要素がネストしている場合にどのボタンが押されたかを正しく検出するためにclosest()を明示的に使うようにしたということですね」「Active StorageにはUJS版のJavaScriptコードもあるんですね: UJS版もちゃんと更新されてサポートされているのは、レガシRailsアプリケーションにとってもありがたい👍」

# activestorage/app/assets/javascripts/activestorage.js#L756
  function didClick(event) {
-   const {target: target} = event;
-   if ((target.tagName == "INPUT" || target.tagName == "BUTTON") && target.type == "submit" && target.form) {
-     submitButtonsByForm.set(target.form, target);
+   const submitButton = event.target.closest(":is(button, input)[type='submit']");
+   if (submitButton && submitButton.form) {
+     submitButtonsByForm.set(submitButton.form, submitButton);
    }

参考: Element: closest() メソッド - Web API | MDN

コメントに具体例があった↓: こういうふうにネストしたボタンが複数ある場合がこれまで想定されていなかったということか」

<%= form_for @model do |form| %>
    <%= form.file_field :image, direct_upload: true %>

    <%= button_tag name: "save_method", value: "draft" do %>
      <span>Save as draft</span>
    <% end %>

    <%= button_tag name: "save_method", value: "publish" do %>
      <span>Save & Publish</span>
    <% end %> 
<% end %>

🔗 非推奨化された機能をRails 7.2に向けて削除

削除・非推奨化された機能

特記ないものは削除です。()内は非推奨化されたときのプルリクです。

  • Action Mailer
    • config.action_mailer.preview_path#31595
    • assert_enqueued_email_withargsキーワード引数(#48194
  • Action Pack
    • ActionDispatch::IllegalStateError定数(#47091
    • AbstractController::Helpers::MissingHelperError定数(#47199
    • ActionController::ParametersHashの比較(#44866
    • allow_deprecated_parameters_hash_equalityコンフィグを非推奨化
    • Rails.application.config.action_dispatch.return_only_request_media_type_on_content_typeコンフィグ(@689b277
    • ブラウザのパーミッションポリシーspeakervibratevrディレクティブ(#46199
    • Rails.application.config.action_dispatch.show_exceptionsへのtrue/falseの設定(#45867
  • Active Job
    • BigDecimal引数のプリミティブなシリアライザ(#45618
    • scheduled_at属性にnumericやepoch値を設定するサポート(#48066
    • retry_onwait: :exponentially_longerオプション(#49292

つっつきボイス:「Rails 7.1までに非推奨化された機能をRails 7.2で削除するプルリクです」「最終的に削除する機能はドラフトの#49624のChangelogにあるけど、今回はこの中からpart 1として選んだものを削除するということか」「Railsで振る舞いを削除するときは、非推奨にしたバージョンを経てから次のバージョンで削除することになっていますよね↓」「こういう作業は@rafaelfrancaが仕切ることが多いですね」

参考: § 5.11.1 振る舞いを削除する場合 -- Ruby on Rails に貢献する方法 - Railsガイド

🔗 MySQLクエリでwarningが発生してもActiveRecord.db_warnings_actionが呼び出されないケースを修正

MySQLが返すwarning_countがゼロより大きく、SHOW WARNINGSクエリを実行しても警告が表示されない場合に、ActiveRecord.db_warnings_actionを呼び出しても警告を無視せずに一般的な警告メッセージを表示するよう修正。

Kevin McPhillips
同Changelogより

問題

ActiveRecord.db_warnings_action:ignore以外の値に設定されている場合、警告があったら「常に呼び出される」ことを期待する。

しかしMySQLでは必ずしもそうなると限らない。AbstractMysqlAdapterでは以下のように2つのステートメントで警告が処理されている。

rails/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb

  return if ActiveRecord.db_warnings_action.nil? || @raw_connection.warning_count == 0 

  @affected_rows_before_warnings = @raw_connection.affected_rows
  result = @raw_connection.query("SHOW WARNINGS")
  result.each do |level, code, message|

しかし単純化したロジックは以下のようになる。

query_result = raw_connection.query(sql)
if raw_connection.warning_count != 0
  warning_result = raw_connection.query("SHOW WARNINGS")
  warning_result.each do |warning|
    ActiveRecord.db_warnings_action(warning)
  end
end

つまりクエリは結果からwarning_countをメタデータとして返す。警告を取得するために2つめの"SHOW WARNINGS"クエリが実行されるが、これは常に正しいと見なされる。warning_countがゼロより大きいにもかかわらず[]を返す例が2つある。

この他にもあるかもしれない。

つまり振る舞いとしては、db_warnings_action:raiseやprocなどに設定されていたとしても決して実行されず、警告も表示されない。

解決方法

warning_countがゼロ以外なのにSHOW WARNINGS[]を返す場合は、エラーメッセージに警告を追加する。このメッセージはとても有用というわけではないが、警告の発生を隠蔽するよりは良い。これで、ユーザーがDBコンフィグを更新したり、カスタムprocを追加したり、警告がユースケースで適切化どうかをトラッキングしたりできるようになる。

また、trilogyでは警告の振る舞いがまったくテストされていなかったことにも気づいたので、最初に警告のテストを追加した(これは基本的にmysql2のテストのコピーである)。

疑問点

  • エラーメッセージの言い回しはこれでいいだろうか?自分ではベストのつもりだが、こだわりはない。
  • db_warnings_actionに関するドキュメントを追加して、この状態に陥ることを防ぐDB調整方法のヒントとすべきだろうか?このプルリクでやるのが適切とは思えないのと、ドキュメントの他の場所でインフラ関連のヒントを示すのがいいかどうかわからない。内容が具体的すぎて陳腐化するかもしれない。
    同PRより

つっつきボイス:「MySQLで警告を出して欲しいのに出されない場合があったのを修正したそうです」「max_error_countの設定を0にしている場合にのみ発生する問題のようだけど、知らないと気づくのが難しい問題ですね」

🔗 DBアダプタ名のエイリアスを利用可能にした

@byrootによる#50064の次のステップ。

問題

DBアダプタ名のエイリアスを使えるようにしたい。このプルリクでの最終状態は、mysqltrilogymysql2にマッピング可能にすること。他のDBアダプタについても同じことができるようにしたい。

ここでの主なリファクタリングは、アダプタのバリデーションをDatabaseConfig.newから切り出して、後で呼び出される#validateに移動すること。

このリファクタリングの理由は、現在のデータベースコンフィグはフレームワークで非常に早期のタイミングで読み込まれるので、コンフィグが読み込まれてから.newでバリデーションされる前にアダプタの登録やエイリアス化にフックする方法が存在していないため。

rails/activerecord/lib/active_record/railtie.rb

initializer "active_record.initialize_database" do
  ActiveSupport.on_load(:active_record) do
    self.configurations = Rails.application.config.database_configuration

アダプタがActive Recordに登録されるタイミングは、「アダプタが読み込まれた後」かつ「コンフィグが読み込まれる前」でなければならない。また、on_load(:active_record)フックが順に実行されるため、上のコードは常にRailsアプリの他のもの(イニシャライザも含む)よりも前に実行される。:active_record_register_adaptersフックを追加しようとしたものの、これが起動される前にアプリのイニシャライザは実行されなかった。

解決方法

@matthewdの提案に沿って、バリデーションをDatabaseConfigのメソッドに切り出す。この方法なら、コンフィグのサブクラスでバリデーションをさらに特殊化できるというボーナスもついてくる。

また、次のステップで使う予定のメソッドもalias("trilogy", as: "mysql")に追加する。

再現手順

この再現手順は、新規Railsアプリで"sqlite3"アダプタ名を"potato"にし、以下の手順でアプリを実行する形で検証した。

$ ../rails/railties/exe/rails new example --dev --skip-asset-pipeline --skip-javascript --skip-hotwire --skip-test
$ sed -i 's/adapter: sqlite3/adapter: potato/' config/database.yml
$ bin/rails environment
bin/rails aborted!
ActiveRecord::AdapterNotFound: database configuration specifies nonexistent 'potato' adapter. Ensure that the adapter is spelled correctly in config/database.yml and that you've added the necessary adapter gem to your Gemfile. (ActiveRecord::AdapterNotFound)
$ echo 'ActiveSupport.on_load(:active_record) { ActiveRecord::ConnectionAdapters.register("potato", "ActiveRecord::ConnectionAdapters::SQLite3Adapter", "active_record/connection_adapters/sqlite3_adapter") }' > config/initializers/active_record_adapters.rb
$ bin/rails environment
$ bin/rails runner 'puts "#{ ActiveRecord::Base.connection.pool.db_config.adapter } / #{ ActiveRecord::Base.connection.adapter_name }"'
potato / SQLite

同PRより


つっつきボイス:「mysqlアダプタにtrilogymysql2みたいなエイリアスを作れるようにしたかったんですね」「やりたいことはシンプルで既存の振る舞いも変わらないけど、初期化のタイミングなどとの関連でそれなりの規模の修正になってますね」

🔗 Active Storageのプロキシで画像のバリアントが返されない問題を修正

ActiveStorage::Representations::ProxyControllerが、previewableなファイルでプレビュー画像のバリアントを正しく返さない問題を修正。

Chedli Bourguiba
同Changelogより

修正issue: #47071

動機/背景

blobがpreviewableとして表示可能な場合、プロキシされるプレビュー画像が常に元のプレビュー画像になり、リクエストで渡されるvariation_keyが完全に捨てられてしまっていた。

3つのクラスは100%互換性があるので、Representable#imageの利用を非推奨にできる。このメソッドは既に古くなっているので、このクラスのユーザーが呼び出す必要はなく、Preview#imageの場合に呼び出すのは間違っている。

詳細

このプルリクは、PreviewVariantWithRecordを編集してVariant APIと完全に動機させることで問題を解決する。これによって、ProxyControllerがrepresentable#imageではなくrepresentableを呼び出せるようになる。

追加情報

この問題を検出するため、これまでなかった「失敗するテスト」を追加した。

ActieStorage::VariantActieStorage::VariantWithRecordActieStorage::Previewに若干の食い違いがあったため、修正は簡単にはいかなかった。

たとえば、ActiveStorage::Variant#imageが(Active Storageのblobではなく)selfを返していたというだけの理由で、ActieStorage::Variantが(send_blob_streamで使われるあらゆるメソッドに加えて)#filenameなどの冗長なメソッドも大量に追加していた。

以下は@representation.imageを呼び出している行:

rails/activestorage/app/controllers/active_storage/representations/proxy_controller.rb

http_cache_forever public: true do
  send_blob_stream @representation.image, disposition: params[:disposition]
end

これをsend_blob_stream @representationを呼び出すように変更して#imageを削除した。これは今後のプルリクで非推奨化できる。
同PRより


つっつきボイス:「プレビュー画像のバリアントが返されなかったら悲しい」「これはバグですね: 7-0-stableにもバックポートされている」「同じ機能のクラスが3つもあったのを整理しながら修正したんですね」


前編は以上です。

バックナンバー(2023年度第4四半期)

週刊Railsウォッチ: Ruby 3.3.0-preview3リリース、IRBのオートコンプリート強化ほか(20231127後編)

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

Rails公式ニュース


CONTACT

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