📝

Rails 8.0(ActiveSupport)で気になった変更点

2025/03/25に公開

masaki です。
この記事では、Rails 8.0(Active Support)の変更点について、CHANGELOG をざっと読んで気になったところをまとめてみました。
メインとなる新機能や大きな変更点についてはすでに色々なブログなどで紹介されていますが、細かいメソッドまわりの小さな変更ってあまり話題にならないですよね。
そこで、自分自身の勉強も兼ねて、そういった細かなところを中心にまとめてみました。
少しでも参考になれば嬉しいです!

本記事の執筆にあたり以下の Rails 8.0.2 の ActiveSupport の CHANGELOG を参照しています。

また、記事中の動作確認については以下のバージョンで実施しています。

  • 変更前の動作例:Rails 7.2.2.1
  • 変更後の動作例:Rails 8.0.2

HashWithIndifferentAccess#stringify_keys の修正

ActiveSupport の HashWithIndifferentAccess は、文字列キーとシンボルキーを相互に扱える便利なハッシュですが、その stringify_keys メソッドは、これまでシンボルキーのみを文字列化していました。たとえば、Rails 8.0.0.rc1 より前では次のような動作となっていました。

変更前の動作例

r = { 1 => 2, :a => 3, "b" => 4 }.with_indifferent_access.stringify_keys
p r #=> {1=>2, "a"=>3, "b"=>4}

このため、キーが整数などシンボル以外の場合、一部だけが文字列に変換される不整合がありました。

変更後の動作例

今回の修正では、stringify_keys がハッシュ内の全てのキー(シンボル、整数、その他)を文字列に変換するようになりました。つまり、先ほどの例は以下のように変化します。

r = { 1 => 2, :a => 3, "b" => 4 }.with_indifferent_access.stringify_keys
p r #=> {"1"=>2, "a"=>3, "b"=>4}

開発者は、stringify_keys を呼び出した後はすべてのキーが文字列であると確信できるため、後続の処理でキーの型に依存したバグを避けられます。

ちなみに、Hash#stringify_keys は Rails 8 以前から、すべてのキー(シンボル、整数、その他)を文字列に変換していました。

r = { 1 => 2, :a => 3, "b" => 4 }.stringify_keys
p r #=> {"1"=>2, "a"=>3, "b"=>4}

今回の変更で HashWithIndifferentAccess#stringify_keysHash#stringify_keys と同じ挙動になったことにより混乱が少なくなることが期待されます。

JSON エンコード時のオプションに escape_html_entities が追加

Rails 8.0.0.beta1 以降、ActiveSupport の JSON エンコード処理に新しいオプション escape_html_entities が追加されました。

従来、Rails で JSON エンコードを行う際は、グローバル設定 ActiveSupport.escape_html_entities_in_json の値に従って、HTML タグなどの特殊文字がエスケープされるかどうかが決まっていました。
しかし、特定のケースでは、エスケープを行いたくない場合もあります。今回の変更により、エンコード時にオプションとして escape_html_entities: false を渡すことで、グローバル設定を上書きし、元の文字列をそのまま JSON に含めることが可能になりました。

変更後の動作例

data = { text: '<div>Hello</div>' }
r1 = ActiveSupport::JSON.encode(data)
p r1 #=> "{\"text\":\"\\u003cdiv\\u003eHello\\u003c/div\\u003e\"}"
r2 = ActiveSupport::JSON.encode(data, escape_html_entities: false)
p r2 #=> "{\"text\":\"<div>Hello</div>\"}"

未検証ですが、controller からは以下のように利用できるようになるとのことです。

class MyController < ApplicationController
  def index
    render json: { text: '<div>Hello</div>' }, escape_html_entities: false
  end
end

Time および ActiveSupport::TimeWithZone 同士の加算の非推奨化

Rails 8.0.0.beta1 で導入された変更のひとつとして、Time オブジェクトや ActiveSupport::TimeWithZone オブジェクト同士の「加算(+ 演算子や since メソッドを用いた加算)」が非推奨となりました。これは、絶対的な日時同士を単純に足し合わせる操作が意味的に曖昧であり、予期しない「未来の日付」が生成されるといった問題があったためです。

変更前の動作例

以下は、ある開発者が「10 日前」の日にちに「5日」を足す、というロジックを実装しようとした例です。

t1 = 10.days.ago
t2 = 5.days.ago
r = t1 + t2
p r #=> Sat, 01 Jun 2080 06:28:24.941719721 UTC +00:00

「年」が 2080 になっています。
ほとんどの開発者にとってこれは意図しない挙動ではないかと思います。
10.days.ago.since(5.days.ago) も同じ挙動になります。

変更後の動作例

t1 = 10.days.ago
t2 = 5.days.ago
r = t1 + t2
#=> DEPRECATION WARNING: Adding an instance of ActiveSupport::TimeWithZone to an instance of ActiveSupport::TimeWithZone is deprecated. This behavior will raise a `TypeError` in Rails 8.1. (called from <main> at (app):3)
p r #=> 2080-06-01 06:37:29.706721390 UTC +00:00

WARNING が出力されるようになりました。
Rails 8.1 では TypeError が raise されるようになるとのことです。

以下のように ActiveSupport::TimeWithZone + ActiveSupport::Duration とするとエラーを避け、意図した値を得られます。

t1 = 10.days.ago
t2 = 5.days
r = t1 + t2
p r #=> 2025-03-20 03:42:21.366298969 UTC +00:00

一般的にはテストなどで予期しない値となることは防げていると思いますが、WARNING(将来的には TypeError)となるのは嬉しい変更だと思いました。

in_order_offilter オプションが追加

in_order_of は、配列や ActiveRecord のコレクションを指定した順序に並べ替えるためのメソッドです。Rails 8.0.0.beta1 から、このメソッドに新たに filter オプションが追加されました。

従来の in_order_of は、優先順位として渡した値のみに基づいて並べ替えを行い、渡されなかった要素は結果から除外される(フィルタリングされる)動作をしていました。

変更前の動作例

class Human
  attr_accessor :id

  def initialize(id)
    @id = id
  end
end

humans = [Human.new(3), Human.new(5), Human.new(1), Human.new(4), Human.new(2)]
priority_order = [2, 1, 3]

r = humans.in_order_of(:id, priority_order)
p r #=> [#<Human:0x0000ffff82429960 @id=2>,
#<Human:0x0000ffff82429a00 @id=1>,
#<Human:0x0000ffff82429ac8 @id=3>]

配列 humans に含まれていた id が 4 や 5 の要素が、in_order_of の結果からは除外されています。

変更後の動作例

class Human
  attr_accessor :id

  def initialize(id)
    @id = id
  end
end

humans = [Human.new(3), Human.new(5), Human.new(1), Human.new(4), Human.new(2)]
priority_order = [2, 1, 3]

r1 = humans.in_order_of(:id, priority_order)
p r1 #=> [#<Human:0x0000ffff7087a5a8 @id=2>,
#<Human:0x0000ffff7087a710 @id=1>,
#<Human:0x0000ffff7087a7b0 @id=3>]

r2 = humans.in_order_of(:id, priority_order, filter: false)
p r2 #=> [#<Human:0x0000ffff7087a5a8 @id=2>,
#<Human:0x0000ffff7087a710 @id=1>,
#<Human:0x0000ffff7087a7b0 @id=3>,
#<Human:0x0000ffff7087a760 @id=5>,
#<Human:0x0000ffff7087a620 @id=4>]

filter オプションを渡さないときの挙動はアップデート前と同じですが、filter: false を渡したときは、元の配列に含まれる id が 4 や 5 のオブジェクトも除外されずに返りました。
この追加オプションにより、より柔軟な並べ替えが可能になり、たとえば全てのレコードを保持しつつ、特定の値を優先的に上位に表示する、といった用途に対応できるようになりました。

travel_to のマイクロ秒部分の挙動修正

Rails のテストヘルパーにおいて、時刻を変更するために用いられる travel_to メソッドの挙動が修正されました。

通常、travel_towith_usec: true の引数が設定されていない限り、マイクロ秒部分が 0 になる、という仕様になっています。
しかし、引数に 日時形式の文字列YYYY-MM-DD HH:MM:SS.SSS)を渡した場合、渡された値のマイクロ秒部分をそのまま扱っていました。

変更前の動作例

require "active_support/testing/time_helpers"
include ActiveSupport::Testing::TimeHelpers

travel_to("2024-12-25 12:34:56.789") do
  current_time = Time.current
  r = current_time.strftime("%Y-%m-%d %H:%M:%S.%6N")
  p r #=> "2024-12-25 12:34:56.789000"
end

マイクロ秒は .789000 と出力されます。

変更後の動作例

require "active_support/testing/time_helpers"
include ActiveSupport::Testing::TimeHelpers

travel_to("2024-12-25 12:34:56.789") do
  current_time = Time.current
  r = current_time.strftime("%Y-%m-%d %H:%M:%S.%6N")
  p r # => "2024-12-25 12:34:56.000000"
end

マイクロ秒が .000000 と出力されるようになりました。

もしマイクロ秒を保持したい場合は、with_usec: true オプションを指定することで対応可能です。

travel_to("2024-12-25 12:34:56.789", with_usec: true) do
  current_time = Time.current
  r = current_time.strftime("%Y-%m-%d %H:%M:%S.%6N")
  p r # => "2024-12-25 12:34:56.789000"
end

最初にこの変更を見たとき、「わざわざミリ秒を指定しているのに切り捨てられると戸惑いそう」と思いましたが、切り捨てするのがデフォルトの挙動 であることを知り、納得しました。
経緯については、以下の Pull Request に記載されています。

まとめ

以上、Rails 8.0 における ActiveSupport の変更点でした。
ここで取り上げた以外にもいくつか変更点がありますが、特に気になったものだけ取り上げました。
参考になれば幸いです。

SocialPLUS Tech Blog

Discussion