Rails 8.0(ActiveSupport)で気になった変更点
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_keys
が Hash#stringify_keys
と同じ挙動になったことにより混乱が少なくなることが期待されます。
escape_html_entities
が追加
JSON エンコード時のオプションに 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_of
に filter
オプションが追加
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_to
は with_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 の変更点でした。
ここで取り上げた以外にもいくつか変更点がありますが、特に気になったものだけ取り上げました。
参考になれば幸いです。
Discussion