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

週刊Railsウォッチ: Ruby 3.3.0-preview1リリース、in_order_ofのバグ修正ほか(20230525後編)

こんにちは、hachi8833です。RubyKaigi 2023でKeebKaigi周りをまったくチェックできなかったのが悔やまれます。

週刊Railsウォッチについて

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

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

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

昨日に引き続き改修を見ていきます。

🔗 バグ修正

🔗 in_order_ofでネストがすべてフラットになる問題を修正

動機/背景
edge Railsで自分のアプリのどこが壊れるか試してみたら、ここが最初に壊れた。
in_order_ofを使っている箇所にハッシュの配列があるのだが、今回の変更でハッシュの配列がソートされなくなり、値がフラット化された長い配列になってしまった。
実際のオブジェクト:

{:closed=>{:value=>0, :status=>"Closed"},
 :opened=>{:value=>1, :status=>"Opened"},
 :temporarily_closed=>{:value=>2, :status=>"Temporarily closed"},
 :recommended=>{:value=>3, :status=>"Recommended"},
 :declined=>{:value=>4, :status=>"Declined"},
 :submitted=>{:value=>5, :status=>"Submitted"},
 :invited=>{:value=>6, :status=>"Invited"}}

従来:

irb(main):001:0> Cafe.symbolized_status_map.in_order_of(:first, [:opened, :submitted, :recommended, :invited, :temporarily_closed, :closed, :decline
d])
[[:opened, {:value=>1, :status=>"Opened"}],
 [:submitted, {:value=>5, :status=>"Submitted"}],
 [:recommended, {:value=>3, :status=>"Recommended"}],
 [:invited, {:value=>6, :status=>"Invited"}],
 [:temporarily_closed, {:value=>2, :status=>"Temporarily closed"}],
 [:closed, {:value=>0, :status=>"Closed"}],
 [:declined, {:value=>4, :status=>"Declined"}]]

現状:

irb(main):001:0> Cafe.symbolized_status_map.in_order_of(:first, [:opened, :submitted, :recommended, :invited, :temporarily_closed, :closed, :decline
d])
=>
[:opened,
 {:value=>1, :status=>"Opened"},
 :submitted,
 {:value=>5, :status=>"Submitted"},
 :recommended,
 {:value=>3, :status=>"Recommended"},
 :invited,
 {:value=>6, :status=>"Invited"},
 :temporarily_closed,
 {:value=>2, :status=>"Temporarily closed"},
 :closed,
 {:value=>0, :status=>"Closed"},
 :declined,
 {:value=>4, :status=>"Declined"}]

そして、以下のようなことをやっている部分で壊れた。infoは当然nilになった😅

<% Cafe.symbolized_status_map.in_order_of(:first, [:opened, :submitted, :recommended, :invited, :temporarily_closed, :closed, :declined]).each do |sym, info| %>

このプルリクはこの問題を修正して元に戻す。
詳細
このプルリクは#47805の実装を改善する。
追加情報
これは意図した振る舞いなのかどうかをDiscordで聞いたところプルリクを投げるようにアドバイスをもらったので、ここにいます😄
同PRより


つっつきボイス:「in_order_ofEnumerableに入ったもの(ウォッチ20210330)とActiveRecord::QueryMethodsに入ったもの(ウォッチ20210831)があるけど、これはActiveRecord::QueryMethodsの方ですね」「ハッシュの配列が誤って全部フラットになったら、そこから先のコードは当然動かなくなりますね」

参考: Rails API in_order_of -- ActiveRecord::QueryMethods
参考: Rails API in_order_of -- Enumerable

「そしてこれも修正は1箇所だけ↓」「わかりやすくて嬉しい」flatten(1)にすることで最初の階層だけをフラットにするのか」「ちなみにcompactnilを配列やハッシュから要素ごと消せるメソッドですね」「compactも便利そう、今度使ってみます」

# activesupport/lib/active_support/core_ext/enumerable.rb#L196
  def in_order_of(key, series)
-   group_by(&key).values_at(*series).flatten.compact
+   group_by(&key).values_at(*series).flatten(1).compact
  end

参考: Array#flatten (Ruby 3.2 リファレンスマニュアル)
参考: Array#compact (Ruby 3.2 リファレンスマニュアル)
参考: Hash#compact (Ruby 3.2 リファレンスマニュアル)

「ところで今ググったらこんな記事を見つけました↓」「[1,2,3,4,5].compact!みたいにnilを含まない配列だとnilが単品で返される...だと?」「これはビックリ」「compact!が付いているけど破壊的ではなくて元の配列はそのままなんですね」「他にもこんな挙動をするメソッドがあった気がする🤔」

参考: Rubyのcompact! に注意。 - Qiita

🔗 pg gem 1.5.0で不要な非推奨警告が表示されるのを修正

最近リリースされたpg gem 1.5.0で以下の警告が出るようになった(Ruby 3.2.2とRails 7.0.4.3)。

PG::Coder.new(hash) is deprecated. Please use keyword arguments instead! Called from ../ruby/3.2.2/lib/ruby/gems/3.2.0/gems/activerecord-7.0.4.3/lib/active_record/connection_adapters/postgresql_adapter.rb:980:in `new'

#48046より


つっつきボイス:「あ、この警告は自分のRails 7.0.4.3アプリでも最近minitestで出るようになってました↓」

$ dip minitest
# 略
PG::Coder.new(hash) is deprecated. Please use keyword arguments instead! Called from /usr/local/bundle/gems/activerecord-7.0.4.3/lib/active_record/connection_adapters/postgresql_adapter.rb:980:in `new'
PG::Coder.new(hash) is deprecated. Please use keyword arguments instead! Called from /usr/local/bundle/gems/activerecord-7.0.4.3/lib/active_record/connection_adapters/postgresql_adapter.rb:980:in `new'
Started with run options --seed 45774

  37/37: [==============================================================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.52181s
37 tests, 65 assertions, 0 failures, 0 errors, 0 skips

「これも修正はシンプルで、**でハッシュを渡すようにしてますね↓」「Ruby 2.7〜3.0で変更された書き方ですね」「まだ残ってたのか」「7-0-stableブランチにもバックポートされているので、次のマイナーリリースで警告は出なくなるそうです」「ありがたい」

# activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L1085
        def update_typemap_for_default_timezone
          if @raw_connection && @mapped_default_timezone != default_timezone && @stamp_decoder
            decoder_class = default_timezone == :utc ?
              PG::TextDecoder::TimestampUtc :
              PG::TextDecoder::TimestampWithoutTimeZone

-           @stamp_decoder = decoder_class.new(@stamp_decoder.to_h)
+           @stamp_decoder = decoder_class.new(**@stamp_decoder.to_h)

            @raw_connection.type_map_for_results.add_coder(@stamp_decoder)

            @mapped_default_timezone = default_timezone
            # if default timezone has changed, we need to reconfigure the connection
            # (specifically, the session time zone)
            reconfigure_connection_timezone
            true
          end
        end

Ruby 2.7: ハッシュからキーワード引数への自動変換が非推奨に(翻訳)

🔗 Rack::Test::UploadedFile.newStringIOが例外を発生する問題を修正

動機/背景
このプルリクを作成した理由は、私たちが使っているRack::Test::UplaodedFileがおかしいのではと思ったため。このオブジェクトは実際のファイルオブジェクトでインスタンス化可能(この部分についてはテストスイートで広範囲にわたってテストされている)だが、StringIOでもインスタンス化できる。残念ながら後者には#openメソッドがないため例外が発生する。

分岐を増やし、背後にどんなオブジェクトがあるかに応じてRack::Test::UploadedFileを使うようにする必要がある(このプルリクではダックタイピングで分岐する方法を提案しているが、他の案も歓迎する)。

詳細
このプルリクでは、ActiveStorage::Attached::Changes::CreateOne#uploadメソッド(およびprivateの#find_or_build_blobメソッド)を変更して、#openメソッドに応答しない場合はRack::Test::UploadedFile#openを呼ばないようにする。ここではオブジェクト自身を利用している(ファイルを開くのと同様にオブジェクトから読み取り可能)。
追加情報
rack-test gemのメンテナーによると、このgemの振る舞いは期待通りであるとのこと(rack/rack-test#336)。
同PRより


つっつきボイス:「StringIOは文字列にIOのメソッドを持たせるクラスなのか」

参考: class StringIO (Ruby 3.2 リファレンスマニュアル)
参考: class IO (Ruby 3.2 リファレンスマニュアル)

「修正はcase文の場合分けを増やす形で対応してますね↓」

# activestorage/lib/active_storage/attached/changes/create_one.rb#L
    def upload
      case attachable
-     when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
+     when ActionDispatch::Http::UploadedFile
        blob.upload_without_unfurling(attachable.open)
+     when Rack::Test::UploadedFile
+       blob.upload_without_unfurling(
+         attachable.respond_to?(:open) ? attachable.open : attachable
+       )
      when Hash
        blob.upload_without_unfurling(attachable.fetch(:io))
      end
    end

🔗 before_updateupdated_atが更新されない問題を修正

動機/背景
修正されるissue: #45389

上のリンク先に書かれているように、updateのコールバックではupdated_atタイムスタンプも更新されることが期待される(この振る舞いはsaveコールバックで既に発生するため)。

詳細
このプルリクは、updateのすべてのコールバックが実行されたらタイムスタンプが更新され、その中で改変が行われていれば更新として認識され、その結果updated_atタイムスタンプが確実に更新されるようにする。

当初は、タイムスタンプを設定するタイミングをすべてのコールバックが完了した後にしようと考えていたが、タイムスタンプが常にコールバック内でアクセス可能でなければならないため、このコミットで行われた変更では、リグレッションが発生することがあった。

また、コールバック内にタイムスタンプが存在することが予想される関連するいくつかのテストのカバレッジも向上した。これらのテストでは、createのコールバックだけでなく、createupdatesaveのコールバック(それぞれ1種類)をテストするようにした。理由は自分のソリューションに取り組んでいるときに存在した唯一のテストに失敗したためで、リグレッションが発生しないように、これら3種類のコールバックすべてをカバーすることが重要だと考えた。

追加情報
デバッグと、失敗するテストの作成(このプルリクで自分がパクったもの)に協力してくれた@markhallenに感謝したい。
同PRより


つっつきボイス:「before_updateupdated_atカラムが更新されないことがある問題ですか」super周りを修正していますね↓」「コールバックがらみだけに、なかなかややこしい修正」

# activerecord/lib/active_record/callbacks.rb#L462
    def _update_record
-     _run_update_callbacks { super }
+     _run_update_callbacks { record_update_timestamps { super } }
    end
# activerecord/lib/active_record/timestamp.rb#L117
   def _update_record
+     record_update_timestamps
+
+     super
+   end
+
+   def create_or_update(touch: true, **)
+     @_touch_record = touch
+     super
+   end
+
+   def record_update_timestamps
      if @_touch_record && should_record_timestamps?
        current_time = current_time_from_proper_timezone

        timestamp_attributes_for_update_in_model.each do |column|
          next if will_save_change_to_attribute?(column)
          _write_attribute(column, current_time)
        end
      end

-     super
-   end
-
-   def create_or_update(touch: true, **)
-     @_touch_record = touch
-     super
+     yield if block_given?
    end

「ところでcreated_atupdated_atってメインのロジックでは普通使わないというか、あまり当てにしてはいけないカラムという感じですよね?」「そうそう、ビジネスロジックで必要なタイムスタンプはそれ専用のカラムを作る方がいいでしょうね: created_atupdated_atはRailsが自動的にサポートしてくれますけど、コールバックやフックが絡んでいてアプリケーション開発者が明示的に操作しているものではありませんし、運用上必要になることももちろんありますが、基本的に参考情報と思っておく方がいいんじゃないかな」「なるほど」

🔗 メソッド名になっているコンフィグキーに代入したらエラーを発生するようにした

最近は、メソッド名になっているコンフィグキーにも代入できるようになっている。

require "rails"

config = Rails::Railtie::Configuration.new
config.eager_load_namespaces = 1

代入した値は当然取り出せない。

config.eager_load_namespaces # [I18n, ActiveSupport, ActionDispatch]

このような代入は無意味なので、APIで防止すべき。現実に以下のような例を目撃したことがある。

config.load_defaults = 7.0

この代入はエラーを出さないので、ユーザーはフレームワークのデフォルトが有効になっていると信じ込んでしまう可能性がある。しかし実際には有効になっていない。

このパッチによって、こうした場合にエラーを発生するようになった。

ここではNoMethodError例外を使うことにした(存在しないセッターメソッドを呼び出そうとしたとも言えるため)。しかしこの特殊な状況をユーザーに理解してもらうために、メッセージはカスタマイズしてある。
同PRより


つっつきボイス:「コンフィグの中にメソッド形式のものがあるって知りませんでした」「config.load_defaults 7.0なら効くけどconfig.load_defaults = 7.0みたいに=を付けてもセッターじゃないから効かないのか!」「=を付けてもエラーにならなかったら見落としても無理ないですね」「これは大事な修正👍」

参考: Rails アプリケーションを設定する - Railsガイド

「修正箇所を見てて気づいたんですが、コンフィグの処理ってmethod_missingでやっているんですね」「Railsはコンフィグを増やすたびにメソッドを増やしたりしていないと思うので、method_missingでやっているのはわかる」

# railties/lib/rails/railtie/configuration.rb#L94
    private
+     def actual_method?(key)
+       !@@options.key?(key) && respond_to?(key)
+     end
+
      def method_missing(name, *args, &blk)
        if name.end_with?("=")
-         @@options[:"#{name[0..-2]}"] = args.first
+         key = name[0..-2].to_sym
+         if actual_method?(key)
+           raise NoMethodError.new("Cannot assign to `#{key}`, it is a configuration method")
+         end
+         @@options[key] = args.first
        elsif @@options.key?(name)
          @@options[name]
        else
          super
        end
      end

参考: BasicObject#method_missing (Ruby 3.2 リファレンスマニュアル)

🔗 ドキュメント・SEO関連


つっつきボイス:「RailsガイドのPostgreSQLガイドにINCLUDEオプションとUNIQUE制約の記述が追加されたそうです」「お〜いいですね😋」「ちなみにPostgreSQLガイド英語版はだいぶ前からWIP(作業中)のままですが、これでWIPが取れる日が1歩近づいたかな」

参考: Active Record と PostgreSQL - Railsガイド
参考: Active Record and PostgreSQL — Ruby on Rails Guides


古株の@p8は、検索エンジンでRailsドキュメントの運勢を改善する作業をやってくれています。
公式更新情報より

「2つ目は、検索エンジンがRails APIドキュメントの情報をちゃんと拾ってくれるように修正してくれたそうです↓」「小さな修正がたくさん入ってますね」「地道な改善ありがたい🙏」

# actionpack/lib/action_controller/api.rb#L7
module ActionController
- # = Action \Controller \Base
+ # = Action Controller \API
  #

🔗Ruby

🔗 Ruby 3.3.0-preview1がリリース(Ruby公式ニュースより)


つっつきボイス:「RubyKaigi 2023の間にいつの間にか3.3.0-preview1がリリースされていました🎉」「早!」「まだリリースノートの項目に空欄が目立つけど、今後続々と埋まっていくんでしょうね」

「MJITに代わってRJITが入った」「Bison依存がなくなってLramaが入った↓」「Bisonなくなるのか〜」「最近のRubyはパーサーをリニューアルしていて、Lramaもその一環という感じですね」「Ruby 3x3みたいにRubyのパーサーリニューアルにもプロジェクト名あるのかな?」

RubyにlramaがマージされてBison依存がなくなった(RubyKaigi 2023)

「YJITもいろいろ高速化されてますね」「--yjit-pauseでYJITを止められるというのもRubyKaigi 2023でも出ていました: ベンチマークでYJITの影響を一時的に止めたりするのに便利そう」

「そうそう、readlineライブラリへの依存もなくなってPure Rubyのrelineに置き換わるそうです」「お〜マジですか!」「BisonやreadlineってRubyをビルドするときにインストールしておくのが定番というイメージですけど、そういう依存が消えるの嬉しい😂」「ちなみにst0012さんもrelineメンテナーの1人です」

ruby/reline - GitHub
ruby/readline-ext - GitHub

参考: GNU Readline - Wikipedia

🔗 技術書典14が開催


つっつきボイス:「そういえばもう技術書典の季節か」「Ruby/Rails関係の書籍もいろいろ出展していますね🎉」「オンラインマーケットの書籍数、想像以上に多い」「自分も技術書典に出してみたいネタをこっそり温めてるので来年出したいな〜」

参考: 技術書典14 :技術書のオンラインマーケット開催中

私もつっつき後に『コードレビューで学ぶ Ruby on Rails』をポチりました。


後編は以上です。

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

週刊Railsウォッチ: withで作成したリレーションをjoinsで指定可能に、キャッシュストアの例外処理を統一ほか(20230524前編)

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

Ruby 公式ニュース

Rails公式ニュース


CONTACT

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