Ruby on Rails PR Digest - 2026年 6月
このページは rails/rails リポジトリにマージされたPull Requestを自動的に収集し、AIで要約したものです。
#57738 Return an empty string from word_wrap when the text is nil
マージ日: 2026/6/15 | 作成者: @55728
- 概要 (1-2文で)
word_wrapヘルパーにnilを渡したときにNoMethodErrorが発生していた問題を修正し、nilの場合でも空文字列 ("") を返すようにしたPRです。これにより他のテキスト系ヘルパーと挙動が揃い、ビューからそのまま呼び出しても安全になりました。
- 変更内容の詳細
もともとの問題点
既存コード(抜粋):
def word_wrap(text, line_width: 80, break_sequence: "\n")
return +"" if text.empty?
...
endここで text が nil の場合、text.empty? の呼び出しで NoMethodError: undefined method 'empty?' for nil が発生していました。
word_wrap(nil) # => NoMethodError
word_wrap("") # => ""(こちらは既にハンドリング済み)他のテキストヘルパーは nil を素通し、もしくは安全な値に変換しており、word_wrap だけが例外を投げる状態でした。
truncate(nil)→nilhighlight(nil, ...)→""simple_format(nil)→"<p></p>"excerpt(nil, ...)→nil
修正内容
ガード条件を nil にも対応させました:
def word_wrap(text, line_width: 80, break_sequence: "\n")
return +"" if text.nil? || text.empty?
...
endポイント:
text.nil? || text.empty?の明示的チェックにしており、blank?などには変更していません。- これにより「空文字列」はこれまで通り空文字列で返す。
- 「空白のみの文字列(例:
" ")」はこれまで通りword_wrapのロジックに渡される(挙動を変えない)。
テスト追加
actionview/test/template/text_helper_test.rb にテストを1件追加:
def test_word_wrap_with_nil
assert_equal "", word_wrap(nil)
endこのテストは修正前は NoMethodError で落ち、修正後はパスすることが確認されています。
- 影響範囲・注意点
影響範囲
- View / helper で
word_wrap(some_maybe_nil_value)のように、nilになる可能性のある値をそのまま渡していた箇所で、例外が出なくなり、代わりに空文字列が返るようになります。 nilを渡して例外が出ることを前提にしているコードがもしあれば(あまり考えにくいですが)、その挙動は変わります。
- View / helper で
挙動の一貫性
word_wrap(nil)→""という仕様は、他ヘルパーの「nilを許容し、ビューとしてレンダリング可能な文字列にする」方針と整合的です。- ただし
truncate(nil)やexcerpt(nil, ...)はnilをそのまま返すため、「すべてのテキストヘルパーがnil→""というわけではない」点は従来通りです。word_wrapは既に「空文字列を返す」仕様だったので、それにnilを揃えた形です。
意図的に変えていない点
blank?を使っていないため、空白だけの文字列" "はこれまで通りword_wrapの処理を通ります。- そのため、「空白のみの文字列を渡したときの改行位置など」は一切変わっていません。
- 参考情報 (あれば)
- 対象メソッド:
ActionView::Helpers::TextHelper#word_wrap - 追加されたテストファイル:
actionview/test/template/text_helper_test.rb - 変更行数:
actionview/lib/action_view/helpers/text_helper.rb: 1行差し替えactionview/test/template/text_helper_test.rb: テスト 1ケース追加 (約5行)
#57737 Stop documenting the removed args: Hash form of assert_enqueued_email_with
マージ日: 2026/6/15 | 作成者: @55728
- 概要 (1-2文で)
assert_enqueued_email_withのドキュメントが、既に削除済みのargs: { ... }形式でのパラメータ付きメールの検証方法を案内していたため、実装に即したparams:形式の説明に修正した PR です。コード変更はなく、RDoc の不整合解消のみです。
- 変更内容の詳細
何が問題だったか
ActionMailer::TestHelper#assert_enqueued_email_with の RDoc に、以下のような説明とサンプルが残っていました:
# If +args+ is provided as a Hash, a parameterized email is matched.
#
# def test_parameterized_email
# assert_enqueued_email_with ContactMailer, :welcome,
# args: { email: 'user@example.com' } do
# ContactMailer.with(email: 'user@example.com').welcome.deliver_later
# end
# endおよび同様の args: { greeting: "Hello" } を使った例。
しかし、現在の実装は以下のようになっており:
args = Array(args) unless args.is_a?(Proc)
...
params === job_kwargs[:params] && args === job_kwargs[:args]args:に Hash を渡すと、Array(args)によって[{ email: 'user@example.com' }]ではなく、[[:email, "user@example.com"]](Hash の to_a に準じた形)になる- 一方、パラメータ付きメール (
with(email: ... )) の情報はjob_kwargs[:params]に格納される - よって、
paramsがnilのまま、nil === { email: ... }という比較が行われ、必ずマッチに失敗する
すなわち、ドキュメント通りに書くと「メールはちゃんと enqueue されているのに、テストは失敗する」という状態になっていました。
何を修正したか
- 「
argsに Hash を渡すと parameterized email がマッチする」という説明文を削除 - それに依存したサンプルコード(
args: { ... }を使った例)も削除 - 既に同じドキュメント内で紹介されている、正しい
params:を使う例に統一
つまり、parameterized mail を検証したい場合は、次のような形だけを推奨するようにしました:
def test_parameterized_email
assert_enqueued_email_with ContactMailer, :welcome,
params: { email: 'user@example.com' } do
ContactMailer.with(email: 'user@example.com').welcome.deliver_later
end
endファイルレベルでは:
actionmailer/lib/action_mailer/test_helper.rbの RDoc コメントから 10 行削除params:を指し示す文を 1 行追加(or 既存の流れに合わせて修正)
コード本体 (assert_enqueued_email_with の挙動) には一切変更はありません。
- 影響範囲・注意点
- テストコード側の影響
- これまでドキュメントに従って
args: { ... }で parameterized mail を検証していた場合、そのテストは既に失敗している(あるいは、挙動が期待と違う)はずです。 - 今後は一貫して
params: { ... }を使う必要があります。
- これまでドキュメントに従って
- 本体コードへの影響
- 実装は変わっていないため、ランタイム挙動には一切変更なし。
- CI も
[ci skip]付きで、実質ノーリスクなドキュメント修正。
- バージョン間の認識差
- 古い Rails バージョンでは
args:Hash 形式が動いていた時期があるため、その記憶のまま最新の Rails に移行すると「昔の書き方がドキュメントに残っているが、実際には動かない」という落とし穴になっていました。 - この PR により、公式ドキュメントからその古い書き方が消えるため、新規利用者は正しい
params:形式だけを学ぶことになります。
- 古い Rails バージョンでは
- 参考情報 (あれば)
対象メソッド:
ActionMailer::TestHelper#assert_enqueued_email_with内部的なマッチ条件(簡略化):
rubyparams === job_kwargs[:params] && args === job_kwargs[:args]parameterized email (
with(...)) の情報はjob_kwargs[:params]に乗るため、メールのパラメータを検証したい場合はparams:だけを見るべきという設計になっています。
#57739 Apply the deprecated scope hash :except option to except, not only
マージ日: 2026/6/15 | 作成者: @55728
- 概要 (1-2文で)
Rails のルーティングでscopeに「非推奨のハッシュ形式」を渡したとき、:exceptオプションが誤って:onlyとして扱われ、指定と逆のルートが生成されていたバグを修正する PR です。あわせて、この退行を検出するテストが追加されています。
- 変更内容の詳細
問題のあった箇所
ActionDispatch::Routing::Mapper#scope に対して、古い書き方である「位置引数+ハッシュ形式」を使った場合の処理にバグがありました。
問題のコード(修正前)は以下のようになっていました。
# actionpack/lib/action_dispatch/routing/mapper.rb
only ||= assign_deprecated_option(deprecated_options, :only, :scope)
only ||= assign_deprecated_option(deprecated_options, :except, :scope) # <- 本来は `except` に代入すべきここで本来は deprecated_options[:except] を except ローカル変数に割り当てるべきところを、誤って only に代入していました。
その結果、例えば以下のような「非推奨の positional-hash 形式」の書き方:
scope({ except: :destroy }) do
resources :posts
endが、内部的には
scope(only: :destroy) do
resources :posts
endと同等に扱われてしまい、意図と真逆のルーティング になる問題が発生していました。
- 本来の期待:
destroyアクションのみ除外し、それ以外(index,show,new,create,edit,update)は生きている。 - 実際の挙動(バグあり時):
destroyのみ生成され、それ以外のルートがすべて消える。
一方で、キーワード引数形式の:
scope(except: :destroy) do
resources :posts
endは正しく動作しており、同じ意味のはずの2つの書き方が食い違う 状態になっていました。
このバグは、ルーティング関連のメソッドを位置ハッシュからキーワード引数へ移行したコミット(3b4255e180)の際に混入した退行です。namespace 周りの別の退行(:path / :shallow_path / :shallow_prefix が無視される問題)は別 PR (#57740) で対応中とされています。
修正内容
修正後は次のように、except ローカル変数に正しく代入するよう変更されています。
only ||= assign_deprecated_option(deprecated_options, :only, :scope)
except ||= assign_deprecated_option(deprecated_options, :except, :scope)これにより、
scope({ except: :destroy })(非推奨の hash 位置引数形式)scope(except: :destroy)(キーワード引数形式)
の両方が同じ意味で解釈され、destroy だけを除いたルーティングが生成されるようになります。
テスト追加
actionpack/test/dispatch/routing_test.rb に以下のテストが追加されています。
test_scope_with_deprecated_except_hash_option
このテストでは、あえて非推奨の hash 形式を使って:
scope({ except: :destroy }) do
resources :posts
endのようなルーティングを定義し、
/posts(index) が 存在することdestroyルートが 除外されていること
をアサートしています。
修正前は /posts が "Not Found" になりテストが落ちるため、except が誤って only として扱われていたことが確認できます。修正後はテストが通ることが確認されています(2 runs, 13 assertions, 0 failures, 0 errors, 0 skips)。
- 影響範囲・注意点
- 影響を受けるのは、
scopeに対して非推奨の「位置引数+ハッシュ形式」を使い、かつexcept:を指定しているケース のみです。- 例:
scope({ except: :destroy }) { resources :posts }
- 例:
- 通常推奨されるキーワード引数形式:
scope(except: :destroy) { resources :posts }は元々正しく動いており、今回の変更で挙動は変わりません。
- つまり、ルーティング定義を徐々にキーワード引数形式へ移行している途中のアプリで、
- 一部に
scope({ except: ... })形式が残っている - そのルートに関するテストが不十分 な場合、本番環境で気づかないまま逆転したルート定義が動いていた可能性 があります。
- 一部に
- この PR により、非推奨形式もキーワード形式と同じ意味にそろえられるため、将来的に形式を移行する際にも挙動が一致します。
- 非推奨であること自体は変わらないので、長期的には
scope(except: ...)などキーワード引数形式への置き換えが推奨されます。
- 参考情報 (あれば)
- 対応 PR:
- この PR: https://github.com/rails/rails/pull/57739
- 関連する退行修正 (
namespaceのオプション無視問題): https://github.com/rails/rails/pull/57740
- バグ導入元となったコミット:
3b4255e180"Use keywords in routing mapper"
#57740 Honor the deprecated namespace hash path options
マージ日: 2026/6/15 | 作成者: @55728
- 概要 (1-2文で)
Rails のルーティング DSL において、namespaceが受け取る 非推奨のハッシュ形式オプション({ path: ... }など)が正しく扱われず、パスの二重付与やshallow_path/shallow_prefix無視といったバグが出ていたのを修正する PR です。キーワード引数形式との挙動の不一致を解消し、非推奨形式でも期待どおりルーティングが生成されるようにしています。
- 変更内容の詳細(あればサンプルコードも含めて)
問題のあったコードパス
namespace メソッドの定義(ActionDispatch::Routing::Mapper)では、キーワード引数にデフォルトで「真偽値として真のダミーオブジェクト」DEFAULT を入れています。
DEFAULT = Object.new.freeze
def namespace(name, deprecated_options = nil, as: DEFAULT, path: DEFAULT,
shallow_path: DEFAULT, shallow_prefix: DEFAULT, **options, &block)
if deprecated_options.is_a?(Hash)
as = assign_deprecated_option(deprecated_options, :as, :namespace) if deprecated_options.key?(:as)
path ||= assign_deprecated_option(deprecated_options, :path, :namespace) if deprecated_options.key?(:path)
shallow_path ||= ...
shallow_prefix ||= ...
...
end
...
endpath/shallow_path/shallow_prefixは初期値としてDEFAULT(truthy)を持っている- そこに対して
path ||= ...としているため、常に左辺が truthy と評価されて右辺が実行されない - 結果として、「非推奨のハッシュ形式で渡された値」がまったく読まれず無視される
さらに悪いことに、その非推奨ハッシュから読み取られなかったキーが **options 側に残り、後続処理で「もとの name を使ったデフォルト path」と「ハッシュに残った :path」が二重に扱われるケースが発生します。
バグの具体例
namespace :admin, { path: "adm" } do # 非推奨のハッシュ形式
resources :posts
end期待されるルーティング:
/adm/postsバグ発生時の実際のルーティング:
/admin/adm/posts # `admin` と `adm` が二重に付く同様に、
namespace :foo, { shallow_path: "bar" } do
resources :posts, shallow: true
end
namespace :foo, { shallow_prefix: "bar" } do
resources :posts, shallow: true
endのようなケースで、shallow_path / shallow_prefix が 完全に無視される という問題がありました。
なお、キーワード引数形式:
namespace :admin, path: "adm" do
resources :posts
endは正しく動作しており、「ハッシュ形式だけが壊れている」状態でした。
修正内容
非推奨ハッシュ形式からの値の取り出しに使用している代入演算子を ||= から = に変更し、すでに正しく動作している :as オプションと同じパターンに揃えました。
修正後:
if deprecated_options.is_a?(Hash)
as = assign_deprecated_option(deprecated_options, :as, :namespace) if deprecated_options.key?(:as)
path = assign_deprecated_option(deprecated_options, :path, :namespace) if deprecated_options.key?(:path)
shallow_path = assign_deprecated_option(deprecated_options, :shallow_path, :namespace) if deprecated_options.key?(:shallow_path)
shallow_prefix = assign_deprecated_option(deprecated_options, :shallow_prefix, :namespace) if deprecated_options.key?(:shallow_prefix)
...
endポイント:
- すべて
if deprecated_options.key?(:xxx)でキーの存在を確認したうえで、=で上書き DEFAULTという sentinel 値を使う設計と矛盾しないように、- 「ユーザがキーワードで与えた値」 > 「非推奨ハッシュで与えた値」 > 「
nameから導かれるデフォルト」という優先度を実現
- 「ユーザがキーワードで与えた値」 > 「非推奨ハッシュで与えた値」 > 「
||=だと「すでにDEFAULT(truthy)が入っているため二度と書き換わらない」ので、ここを素直な=に変えることで、期待どおり deprecated ハッシュから値を採用できるようになった
テストの追加
actionpack/test/dispatch/routing_test.rb に以下のテストが追加されています。
test_namespace_with_deprecated_path_hash_optiontest_namespace_with_deprecated_shallow_path_hash_optiontest_namespace_with_deprecated_shallow_prefix_hash_option
それぞれ、すでにある「キーワード引数形式のテスト」をほぼそのまま「ハッシュ形式」で書き直したものになっており、以下を確認しています。
pathハッシュオプションを使っても、パスが二重にならず期待どおりの URL になるshallow_path/shallow_prefixのハッシュオプションが正しく shallow ルーティングに反映され、ヘルパーも期待どおり定義される
修正前はこれら 3 テストが失敗し、修正後は既存のテストも含めてすべて成功することが確認されています。
- 影響範囲・注意点
影響を受けるアプリケーション
namespaceを以下のような 非推奨ハッシュ形式で呼び出しているアプリが対象です:rubynamespace :admin, { path: "adm" } do # ... end namespace :users, { shallow_path: "u" } do # ... end namespace :api, { shallow_prefix: "api_v1" } do # ... endこれまで:
pathが二重になっていたり、shallow_path/shallow_prefixが無視されていたり、- それに起因してヘルパーメソッドが期待どおり定義されていない、 といった「おかしな挙動」が出ていた場合、それが修正されます。
互換性について
- 非推奨とはいえ、このハッシュ形式は「まだサポート対象」であるため、今回の修正は 本来期待される動作に近づけるためのバグ修正 です。
- ただし、もし既存のアプリが「不具合を前提にしたワークアラウンド」(例: パスの二重付与を見越して
pathを調整している等)を行っていた場合、今回の修正でルーティングのパスが変わる可能性があります。 - 実運用では、以下の確認を推奨します。
rake routesで namespace 周りのルーティングが変化していないか- 特に shallow ルーティング (
shallow: true) と組み合わせている namespace の URL とヘルパー名を確認
推奨される対応
新規コード・既存コードともに、非推奨のハッシュ形式はできるだけ早くキーワード引数形式に書き換えることが推奨されます。
ruby# 旧 (非推奨) namespace :admin, { path: "adm", shallow_path: "a" } do # ... end # 新 (推奨) namespace :admin, path: "adm", shallow_path: "a" do # ... end今回の修正により、両方の書き方が同じ結果になることが保証されるため、移行作業はやりやすくなっています。
- 参考情報 (あれば)
- このバグは
namespace/scopeをキーワード引数に移行したコミット3b4255e180 ("Use keywords in routing mapper")で混入した回帰です。 - 兄弟の問題として、
scopeにおける:exceptと:onlyの取り扱いバグ(:exceptが誤ってonlyに割り当てられフィルタリングが反転する)があり、これは別 PR#57739で修正されています。 - ルーティング DSL では、オプションの取り扱い順序・デフォルト値(sentinel を使うかどうか)・キーワード/ハッシュの双方を同じ意味に保つ必要があり、今回のような「
||=の機械的な置き換え」が回帰を生みやすいことが示唆されています。
#57721 Return an Enumerator from Parameters#select / #reject when no block is given
マージ日: 2026/6/15 | 作成者: @55728
- 概要 (1-2文で)
ActionController::Parameters#select/#rejectをブロックなしで呼び出したとき、これまで例外が発生していた挙動を修正し、Hashと同様にEnumeratorを返すようにした PR です。Rails の Strong Parameters が Ruby 標準のHashのインターフェースとより一貫するようになります。
- 変更内容の詳細
何が問題だったか
ActionController::Parameters には Hash と似たインターフェースを提供するためのラッパーメソッドが多数あります:
each_paireach_valuetransform_values,transform_values!transform_keys,transform_keys!selectreject
このうち、多くのメソッドは「ブロックが渡されなかった場合には Enumerator を返す」という Ruby 標準の Hash と同じ挙動を実装するために、以下のようなガードを持っていました。
return to_enum(:transform_values) unless block_given?しかし select / reject にはそのガードが存在せず、代わりに次のような実装でした。
def select(&block)
new_instance_with_inherited_permitted_status(@parameters.select(&block))
endそのため、ブロックなしで呼び出した場合:
params = ActionController::Parameters.new(a: 1, b: 2)
# 期待される (Hash 互換な) 挙動
# params.select #=> Enumerator (e.g. params.select.with_index {...})
params.selectは内部的に @parameters.select(&nil) が実行され、@parameters.select 自体は Enumerator を返すものの、その戻り値を new_instance_with_inherited_permitted_status に渡してしまいます。
new_instance_with_inherited_permitted_status は内部で each_key など、Hash 前提のメソッドを呼ぶため、Enumerator に対して each_key を呼び出そうとして以下のような例外になります。
NoMethodError: undefined method `each_key' for #<Enumerator: ...>つまり:
Hash#select/Hash#reject→ ブロックなしだとEnumeratorを返す (OK)Parameters#select/#reject→ ブロックなしだとEnumeratorを内部でさらに処理してしまいNoMethodError(NG)
という不整合が起きていました。
修正内容
select / reject に、既存の transform_values と同じ形式のガードを追加しました。
def select(&block)
return to_enum(:select) unless block_given?
new_instance_with_inherited_permitted_status(@parameters.select(&block))
end
def reject(&block)
return to_enum(:reject) unless block_given?
new_instance_with_inherited_permitted_status(@parameters.reject(&block))
endこれにより:
params = ActionController::Parameters.new(a: 1, b: 2)
enum = params.select
# enum は Enumerator
enum.with_index { |(k, v), i| ... } # などが可能
enum2 = params.reject
# こちらも Enumerator として利用可能という、Hash と同様の利用パターンがサポートされます。
テスト
actionpack/test/controller/parameters/accessors_test.rb に以下に相当するテストが追加されています:
#selectにブロックを渡さない場合、Enumeratorを返すこと#rejectにブロックを渡さない場合、Enumeratorを返すこと
既存の「transform_values はブロックなしで Enumerator を返す」テストと同パターンのテストが select / reject に対しても追加されており、修正前は NoMethodError で落ちるケースだったことが確認されています。
- 影響範囲・注意点
影響範囲
ActionController::Parameters#select/#rejectを「ブロックなしで」呼び出すコードに影響があります。- 修正前は「内部で例外が発生していた」ケースなので、基本的には「これまでクラッシュしていたコードが正しく動くようになる」方向の変更です。
- 通常の、ブロック付きの
params.select { ... }/params.reject { ... }の挙動は変わりません。
互換性上の注意点
- もしアプリ側で「
params.selectを呼ぶと例外になること」を前提にしたワークアラウンドをしていた場合、その挙動は変わりますが、実用上そのような前提を持つコードはまずないと思われます。 - Ruby の
Hashと同じくEnumeratorが返るため、select.with_indexやreject.with_indexのようなチェインがそのまま利用可能になります。
- もしアプリ側で「
セキュリティ・Strong Parameters 的な注意点
new_instance_with_inherited_permitted_statusの呼び出しは、ブロックが渡された場合のみ行われます (return to_enumで早期リターンするため)。- したがって、実際に
Parametersの新しいインスタンスを生成して permitted 状態を引き継ぐ処理の流れ自体はこれまで通りで、Strong Parameters のセキュリティ特性に変化はありません。 Enumerator経由で要素を列挙するだけでは permitted / unpermitted 状態そのものが変わるわけではありません。
- 参考情報 (あれば)
- Ruby 本体の仕様:
Hash#select/Hash#rejectはブロックなしで呼ばれるとEnumeratorを返す:rubyh = { a: 1, b: 2 } h.select #=> #<Enumerator: {...}:select> h.select.with_index { |(k, v), i| ... }
- Rails 内での類似実装:
ActionController::Parameters#transform_values/#transform_keysなどは既にreturn to_enum(:method_name) unless block_given?で同様の取り扱いをしており、今回の変更によりselect/rejectもそれに揃えられました。
#57735 [RF Docs] Rails Internationalization (#57381)
マージ日: 2026/6/15 | 作成者: @p8
- 概要 (1-2文で)
Rails ガイドの「国際化 (I18n)」ドキュメントを大幅にリライトし、初心者にも読み進めやすい構成と、より具体的なコード例・出力例を備えた内容に刷新した PR です。あわせて、古い情報の削除や他ガイドとの表記・スタイルの整合も図られています。
- 変更内容の詳細
2-1. ガイド全体構成の大幅な再編成
旧構成を見直し、以下の流れに整理し直したとの記述があります:
- 初心者向けの導入(「どういうときに I18n を使うか」「このガイドを読むと何ができるようになるか」)
- 「リクエスト間でロケールを管理する」セクションを独立させて前半に配置
- URL パラメータやセッションなどから locale を決める話が分かりやすくまとまるように
- I18n API の機能紹介(
I18n.t,I18n.l, pluralization, interpolation など) - より高度なトピック(バックエンド差し替え、例外ハンドリングなど)
導入部分は、他のガイドと同様に「After reading this guide, you will know...」形式に書き換えられ、ステップ一覧的な説明から「学べることの一覧」に変更。
2-2. 古い / 不正確な情報の整理・削除
- 削除・変更点の例:
- 「Contributing」セクション: 非アクティブなフォーラムへの言及を削除。
- 「Translating Model Content」セクション: サードパーティ gem へのリンクを削る方針になり、I18n ガイドの範囲を Rails 標準機能の説明に絞る方向へ。
- 「将来の Rails バージョンで public/assets の自動ローカライズが入るかも」といった将来予測的な文言を削除(実現されておらず計画もないため)。
- 「Using Different Exception Handlers」セクションを「Handling Missing Translations / Handling I18n Exceptions」相当の内容に整理し直し、Rails における MissingTranslationData の扱い説明にフォーカス。
2-3. コード例と出力例の大幅追加
ガイド全体で「説明だけ」で済ませていた箇所に、コードとその結果(コメントなど)を多数追加しています。代表的なもの:
基本的な翻訳例の明確化
# config/locales/en.yml
en:
hello: "Hello world"I18n.t("hello") # => "Hello world"といった形で、キーと実際の I18n.t の呼び出し・戻り値を並べて説明するスタイルに統一。
ActiveModel / ActiveRecord メッセージの例
- バリデーションメッセージの設定と結果をコードで明示:
en:
activerecord:
errors:
models:
user:
attributes:
name:
blank: "must be present"user = User.new(name: "")
user.valid?
user.errors.full_messages
# => ["Name must be present"]User.model_name.humanなどのメソッドもコードブロックで例示:
en:
activerecord:
models:
user: "Account"User.model_name.human
# => "Account"スコープ・ネストされたキーの例
「Basic Lookup, Scopes, and Nested Keys」の各例について、I18n.t の戻り値もコメントで併記:
en:
date:
formats:
short: "%b %d"I18n.t("date.formats.short")
# => "%b %d"HTML セーフ / エスケープの例と出力 HTML
Safe HTML 翻訳の章で、app/views/home/index.html.erb のレンダリング結果 HTML も追加:
<%= t("welcome_html") %>en:
welcome_html: "Welcome <strong>World</strong>"結果:
Welcome <strong>World</strong>など、「どこでエスケープされるか」が目で追いやすいように。
変数の挿入と通貨表現の例の改善
Passing Variables to Translationsセクションでは、通貨記号$と€が混在する、不自然な例を修正。- 例: 「オランダ語 (dutch) は
€ 100、スペイン語 (spanish) は100 €」のように、言語によるフォーマット差を説明する例に置き換え。
- 例: 「オランダ語 (dutch) は
number_to_currencyに関する注意は、一般的な NOTE スタイルの注意書きに統一。
number_to_currency(100, locale: :nl)
# => "€ 100"
number_to_currency(100, locale: :es)
# => "100 €"例外ハンドリング・Missing Translation の例
I18n::MissingTranslationData について、実際の例を追加:
I18n.t("missing")
# => raises I18n::MissingTranslationData (Translation missing: en.missing)および Rails 側でのハンドリング・カスタムハンドラ導入方法を説明する流れに整理。
2-4. 「リクエスト間でロケールを管理する」章の独立化・強化
Managing the Locale across Requestsをトップレベルセクションとして独立。- その中で、以下のようなパターンごとに小見出し化:
- 「Setting the Locale from a request parameter」(リクエストパラメータ
params[:locale]で決定) - セッション / ユーザ設定 / Accept-Language など(詳細は PR コメント上では「要対応」となっているが、少なくともパラメータでの設定例はヘッダ付きで整理)。
- 「Setting the Locale from a request parameter」(リクエストパラメータ
- 例(代表的な Rails コントローラのパターン):
class ApplicationController < ActionController::Base
before_action :set_locale
def set_locale
I18n.locale = params[:locale] || I18n.default_locale
end
end2-5. バックエンド差し替え・他ビルトインメソッドの説明明確化
- 「Using Different Backends」セクション:
- 以前は「追加のバックエンドをチェインするコード例」までしかなかったが、「どう使うのか」まで踏み込む説明を追加。
- 例: SimpleBackend と KeyValue backend を組み合わせる、など。
- 「Overview of Other Built-In Methods that Provide I18n Support」:
number_to_currency,distance_of_time_in_words,time_ago_in_wordsなど、I18n 対応のヘルパは名前だけでなく、短いコード例付きで紹介されるように。
- 影響範囲・注意点
- 影響対象は Rails ガイド(ドキュメント)のみで、アプリケーションコードやフレームワーク本体の挙動には変更なし。
- ただし:
- 既存のガイドへのリンク(特定セクションへのアンカー)を社内ドキュメントやブログなどで参照している場合、セクション名・見出し構造が変わっている可能性があるため、リンク切れに注意。
- I18n ガイド内からサードパーティ gem への誘導が削られているため、「モデルの翻訳」などを学ぶ際は別途 gem ドキュメントを参照する必要がある。
- ガイドが初心者向けに再構成されているため、新しく I18n を導入する開発者は、最新ガイドに合わせてチュートリアル的に読み進めるのが推奨されます。
- 参考情報 (あれば)
- 元の改善提案・TODO リスト: https://github.com/rails/rails/pull/51840#discussion_r1603318416
- この PR: [RF Docs] Rails Internationalization (#57381) / #57735
- 関連ファイル(主に内容が変わったガイド):
guides/source/i18n.md(メインの I18n ガイド、+1144/-603 と大幅更新)guides/source/getting_started.md(I18n への導入リンクや記述の調整)guides/source/ruby_on_rails_guides_guidelines.md(ガイド執筆ガイドラインの微調整)
#57734 [ci skip] Remove orphaned backtick from routing docs
マージ日: 2026/6/15 | 作成者: @tylerlwsmith
概要 (1-2文で)
Rails のルーティングガイド(routing.md)の「Path and URL Helpers」セクションに紛れ込んでいた不要なバッククォート(`)1文字を削除した、ドキュメントのみの修正です。機能や挙動の変更は一切なく、表記・レイアウトの体裁を整えるためのものです。変更内容の詳細 (あればサンプルコードも含めて)
- 対象ファイル:
guides/source/routing.md - 変更内容:
- 該当セクション内の文章中に、Markdown のインラインコードとして閉じていない「余計なバッククォート」が 1 文字存在していたのを削除しています。
- 実際の差分は「1 行においてバッククォートを 1 文字削除」という最小限の変更で、文章やコード例の意味自体は変わっていません。
イメージとしては、例えば以下のような状態になっていたものを:
You can use `user_path(@user)`` to get the path...これを:
You can use `user_path(@user)` to get the path...のように修正した、というタイプの変更です(実際の文面はこれと多少異なる可能性がありますが、性質としては同じです)。
- 影響範囲・注意点
- 影響範囲:
- Rails 本体のコード・API には一切影響ありません。
- 影響するのは Rails Guides の表示のみで、Markdown → HTML 変換後の見た目(インラインコードの崩れ、余計な記号の表示)が解消されます。
- 注意点:
- ドキュメント修正のみのため、アプリケーション側で対応すべきことはありません。
- CI を回さないための
[ci skip]がタイトルに付いていますが、これはコード変更がないドキュメント PR でよく行われる運用上の指定であり、特別な意味はありません。
- 参考情報 (あれば)
- 該当セクション: Rails Guides > Routing > Path and URL Helpers
- 目的: Markdown 記法上のタイポ修正によるドキュメント品質の向上であり、API 仕様やルーティングの挙動に関する変更・補足は行っていません。
#57381 [RF Docs] Rails Internationalization
マージ日: 2026/6/15 | 作成者: @Ridhwana
- 概要 (1-2文で)
Railsガイドの「Rails Internationalization (I18n)」ドキュメントを大幅に書き換え、構成を初心者フレンドリーに再編しつつ、API・ベストプラクティス・例外処理などを具体的なコード例付きで整理したPRです。既存のガイドラインとの整合性を取りつつ、古い情報や不要なセクションも整理しています。
- 変更内容の詳細
2-1. ガイド全体構成の再編成
- I18nガイド
guides/source/i18n.mdが大きく書き換えられています(+1144 / -603 行)。 - 構成の方針:
- 初心者向けの導入
- 導入部を「I18nを実装するための手順の要約」から、他のガイドと同様の
「このガイドを読むと次のことが分かります(After reading this guide, you will know…)」形式に変更。 - 最初に、用語の定義・YAMLの基本・
I18n.tの基本的な使い方などをまとめることで、Rails初心者でも読み進めやすい構成にしています。
- 導入部を「I18nを実装するための手順の要約」から、他のガイドと同様の
- リクエスト単位のロケール管理を独立セクション化
- 「Managing the Locale across Requests」をトップレベルの大きなセクションに昇格。
- 実際のアプリ開発で一番迷いがちな「どこで locale を決めるか?」にすぐ到達できるようナビゲーションを改善。
- API機能の説明 → 高度なトピックという流れ
- 下位セクションに、バックエンドの切り替え・例外処理・カスタムフォーマットなど、より高度な内容をまとめています。
- 初心者向けの導入
2-2. 導入・基本セクションの改善
導入文を全面的に書き換え:
- 旧: 実装手順の箇条書きなど、他ガイドとフォーマットがズレていた。
- 新: 「このガイドを読んだあとに理解できること」の列挙(標準形式)。
キーと翻訳の関係を、文章説明だけでなくコード例で示すように変更:
ruby# config/locales/en.yml en: hello: "Hello world"rubyI18n.t('hello') # => "Hello world"ActiveModel / ActiveRecord のバリデーションメッセージも、実際の出力例をコードで示すように追記。
2-3. 「リクエスト間でのロケール管理」セクションの強化
- 「Managing the Locale across Requests」関連の内容を整理・昇格:
- 例: パラメータからロケールを設定する例に、明示的な見出しを追加:
- 「Setting the Locale from a request parameter」(
params[:locale]を使うパターン)
- 「Setting the Locale from a request parameter」(
before_actionなどを使ってI18n.localeを決める標準的なやり方を、より読みやすく整理。
- 例: パラメータからロケールを設定する例に、明示的な見出しを追加:
- ガイド全体の流れとして、
- ロケールとは何か
- 翻訳を書いて
I18n.tする - 実際のリクエストでどのロケールを使うか決める という順序になるよう再構成されています。
2-4. 翻訳の詳細なAPI解説の充実
基本的な lookup / スコープ / ネスト
「Basic Lookup, Scopes, and Nested Keys」部分で、単に
I18n.tの呼び出しだけではなく、戻り値の例も追加:yaml# config/locales/en.yml en: dashboard: title: "Dashboard" welcome: "Welcome, %{name}"rubyI18n.t('dashboard.title') # => "Dashboard" I18n.t('dashboard.welcome', name: "Alice") # => "Welcome, Alice"
変数埋め込み(%{})と通貨の例
- 「Passing Variables to Translations」の例を修正:
- 通貨例が
$と€の混在だったものを、ロケールに応じた通貨表記の違いをより現実的に示すように変更(例: オランダ語とスペイン語で€ 100/100 €の違い)。
- 通貨例が
number_to_currencyについての説明を通常テキストから明示的な NOTE に変更し、「フォーマットはヘルパ側に任せられる」ことを明確化。
Safe HTML とエスケープ
- 「Using Safe HTML Translations」セクションで、
html_safeの扱いを説明する際に生成されるHTMLの例を追加:- どこまでがエスケープされ、どこからはされないのかが明示されるように。
2-5. Active Record / モデル関連の翻訳
- 「Translations for Active Record Models」などで、これまでは文章中に埋め込まれていたメソッド名を、コードブロックで明示:ruby
User.model_name.human # => "Account" # など、ロケールに応じたモデル名 User.human_attribute_name(:email) # => "Email address" - モデル内容の翻訳に関する「Translating Model Content」セクションから、サードパーティgemへのリンクを削除:
- 公式ガイドとしては外部gemの宣伝をせず、必要ならイントロの注意書きレベルの記述に留める方針に変更。
2-6. カスタムフォーマット・バックエンド・例外処理など高度な機能
カスタム翻訳・フォーマット
「Custom Translations」セクションの例に対し、出力例を追加:
yaml# config/locales/en.yml en: date: formats: short: "%b %d"rubyI18n.t("date.formats.short") # => "%b %d"日付・時刻・数値フォーマッタとの連携など、フォーマットの役割をより明示的に説明。
複数バックエンドの利用
- 「Using Different Backends」セクションを拡張し、
- バックエンドをチェーンする例に加えて、
- 「バックエンドを追加したあとどう使うか」を、より具体的なコード例で説明。
- 例: メモリ内バックエンド + データベースバックエンドなど。
- ※実装詳細までは踏み込み過ぎず、「RailsのI18nは pluggable backend である」ことを分かりやすく。
例外処理・Missing translations
- 「Using Different Exception Handlers」セクションを、より役割を明確にするために
- 「Handling Missing Translations」または「Handling I18n Exceptions」的な名前に変更。
I18n::MissingTranslationDataの実際の例を追加:rubyI18n.t('missing') # Translation missing: en.missing # (I18n::MissingTranslationData)- Rails側でカスタムハンドラを設定してメッセージを変えたりログを取る、といった標準的なパターンについても説明を補強。
- 他の例外についても、少なくとも言及や簡単な例を追加(完全な網羅は今後の課題として残タスク扱い)。
2-7. 古い情報・ガイドライン不整合の整理
- Contributing セクションの削除
- 非アクティブなフォーラムを参照していたため削除。
- Authors セクションの削除予定
- 他のガイドで著者を明示していない方針と揃えるため、このPRでは未完了タスクとしてマークされているが、方向性としては削除。
- 将来計画として書かれていた以下の一文を削除:
- 「Future Rails versions may well bring this automagic localization to assets in public, etc.」
- 実際に実装予定・ロードマップがないため、読者に誤解を与えないようにする目的。
2-8. 関連する他ガイドの微修正
guides/source/action_view_overview.md- I18n関連の説明やリンクを、更新されたI18nガイド構成に合わせて微調整(1行追加 / 2行削除レベル)。
guides/source/getting_started.md- Getting Started ガイドからI18nへの誘導文を現行のI18nガイドの構成に合わせて調整(28行ずつ増減の範囲で文章の書き換え)。
guides/source/ruby_on_rails_guides_guidelines.md- ドキュメント執筆ガイドラインの中で、I18nガイドに関係するポリシー(例: 認証情報の表記、サンプルコードスタイルなど)に関する小変更(+2 / -2)。
guides/source/documents.yaml- ガイド一覧でのI18nガイドのタイトルや説明の更新。ナビゲーションに出る説明文も、新しい方針に合うように調整。
guides/assets/images/i18n/demo_html_safe.png- 画像ファイルは差分上は変更なし(+0/-0)だが、参照箇所の文脈が変わっている可能性あり。
- 影響範囲・注意点
コードへの影響:
- 変更はすべてガイド(ドキュメント)のみであり、Rails本体の挙動・APIには影響なし。
- 既存のI18nコードが壊れる / 警告が出るといった影響はありません。
開発者への実務的影響:
- これまでグレーだったベストプラクティス(特に「どこでロケールを決定するか」「例外をどう扱うか」)が明確になり、新しくI18n対応を始めるチームにとっての参照元が改善されます。
- 既存プロジェクトで独自にやっていたパターンが、ガイドの推奨とズレている場合は、
- どこを標準寄りに寄せるか
- 逆にどこをあえてカスタムとして残すか
の検討がしやすくなります。
ドキュメント参照先の変更:
- Getting Started / Action View Overview からI18nガイドへのリンク・説明が変更されているため、
チーム内で「◯◯セクション参照」と共有していた場合は、新しい節タイトルに合わせて更新が必要なことがあります。
- Getting Started / Action View Overview からI18nガイドへのリンク・説明が変更されているため、
未完のTODO:
- PR説明文上でチェックが外れているタスク(Authors セクション完全削除、他例外の例追加など)がいくつか残っているため、今後のPRでさらに細部が変更される可能性があります。
- 参考情報 (あれば)
Rails Guides: Internationalization in Rails(このPRで更新されたガイドの本体)
https://guides.rubyonrails.org/i18n.html (Edge / Stableで内容は若干異なる可能性あり)Railsガイド執筆ガイドライン
https://edgeguides.rubyonrails.org/ruby_on_rails_guides_guidelines.htmlI18n gem(RailsのI18n機能の基盤となるgem)
https://github.com/ruby-i18n/i18n
#57726 Fix max_resumptions attribute name in ResumeLimitError docs
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
ActiveJob::Continuation::ResumeLimitErrorの RDoc に誤って記載されていたクラス属性名max_resumesを、正しいmax_resumptionsに修正したドキュメント専用のPRです。コード挙動自体は一切変わらず、ドキュメントの記述だけが1行修正されています。
- 変更内容の詳細(あればサンプルコードも含めて)
対象:
activejob/lib/active_job/continuation.rb内の RDoc コメント問題点:
エラークラス
ActiveJob::Continuation::ResumeLimitErrorのコメントで、次のように書かれていました:ruby# Raised when a job has reached its limit of the number of resumes. # The limit is defined by the +max_resumes+ class attribute. class ResumeLimitError < Error; endしかし実際のクラス属性は
max_resumptionsであり、max_resumesというクラス属性は存在しません。ActiveJob::Continuableモジュール内では、正しくmax_resumptionsが定義・説明されていますが、この RDoc の1箇所だけ表記揺れ/誤りがありました。
修正内容:
上記コメントの
+max_resumes+を、正しい+max_resumptions+に変更:ruby# Raised when a job has reached its limit of the number of resumes. # The limit is defined by the +max_resumptions+ class attribute. class ResumeLimitError < Error; end
想定される誤利用例:
誤ったドキュメントを信じて、ジョブクラス側で以下のように書いてしまうと:
rubyclass MyJob < ApplicationJob self.max_resumes = 3 end実際には
max_resumes=が定義されていないため、NoMethodError: undefined method 'max_resumes='が発生していました。
正しい使用例:
rubyclass MyJob < ApplicationJob # ジョブが再開(resume)できる回数の上限を設定 self.max_resumptions = 3 end
- 影響範囲・注意点
影響範囲:
- ランタイムコードには一切変更がなく、挙動に影響はありません。
- 既存の
max_resumptionsの利用コードはそのまま問題なく動作します。 - これまで RDoc やコメントだけを見て
max_resumesを使っていた場合、既にエラーになっていたはずで、このPRによって新たに壊れるコードはありません。
注意点:
- ActiveJob の Continuation/Continuation::ResumeLimitError を使っているプロジェクトで、もし
max_resumesを使おうとしていた・コメントだけを頼りにしていた場合は、クラス属性名がmax_resumptionsであることを改めて確認してください。 - CIスキップ (
[ci skip]) のタグが付いているため、この変更は完全にドキュメント更新扱いです。
- ActiveJob の Continuation/Continuation::ResumeLimitError を使っているプロジェクトで、もし
- 参考情報 (あれば)
- 対象クラス/モジュール:
ActiveJob::ContinuableActiveJob::Continuation::ResumeLimitError
- 概念メモ:
max_resumptionsは、継続可能ジョブ(continuable job)がresumeされる回数の上限を指定するクラス属性です。- 上限に達すると
ResumeLimitErrorが発生し、その説明コメントが今回の修正対象でした。
#57730 Don't mask errors raised inside a cursor's succ in Step#advance!
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
ActiveJob::Continuation::Step#advance!がカーソルのsucc呼び出し時に発生する本来のNoMethodErrorを飲み込んで別エラーにすり替えていた問題を修正し、succを実装していないカーソルだけを明示的に弾くように変更した PR です。これにより、カスタムカーソルのsucc内部のバグが正しく例外として表に出るようになります。
- 変更内容の詳細
変更前の挙動
Step#advance! は、succ を持たないカーソルに対して分かりやすいエラーを出すために、NoMethodError を rescue して UnadvanceableCursorError に変換していました:
def advance!(from: nil)
from = cursor if from.nil?
begin
to = from.succ
rescue NoMethodError
raise UnadvanceableCursorError, "Cursor class '#{from.class}' does not implement 'succ'"
end
set! to
endしかしこの実装だと、
- カーソルオブジェクトが
succを定義している - その
succの「内部」でNoMethodErrorが発生した
という場合でも、元の NoMethodError が rescue されてしまい、常に
UnadvanceableCursorError: Cursor class 'MyCursor' does not implement 'succ'という誤ったエラーになり、実際の原因やバックトレースが隠蔽されてしまっていました。
変更後の実装
succ を事前に respond_to? でチェックし、未実装の場合のみ UnadvanceableCursorError を投げるように変更されました:
def advance!(from: nil)
from = cursor if from.nil?
unless from.respond_to?(:succ)
raise UnadvanceableCursorError, "Cursor class '#{from.class}' does not implement 'succ'"
end
set! from.succ
endポイント:
respond_to?(:succ)で「メソッドが存在するかどうか」だけを確認- その後の
from.succ実行時に発生した例外 (NoMethodErrorを含む) はそのまま外に伝播する- つまり、
succ実装のバグは本来の例外としてデバッグ可能な形で見える
- つまり、
テスト追加
succを実装しているが、その中で「別の理由」でNoMethodErrorを投げるカーソルクラスを用意し、- その
NoMethodErrorがUnadvanceableCursorErrorにラップされず、そのまま伝播することをテスト
- その
- 既存のテスト (
nil/Float/Integerなどのカーソルに対する挙動) は変更なしで通ることを確認
- 影響範囲・注意点
- 影響を受けるのは:
ActiveJob::Continuationを使っていて、独自のカーソルオブジェクトをStepに渡し、そのオブジェクトにsuccを実装しているケース
- これまで:
- カーソルの
succ内部でNoMethodErrorが起きると、UnadvanceableCursorErrorとして報告され、原因特定が難しかった
- カーソルの
- 今後:
succが存在するカーソルでは、succ内の例外はそのまま表に出る
→ バグが正しいエラー種別・スタックトレースで見えるようになるsuccを実装していないカーソル (nil,Float,Arrayなど) には、従来通りUnadvanceableCursorErrorが発生succが private な場合もrespond_to?(:succ)はfalseを返すため、以前と同様に「実装されていないもの」として扱われる
互換性の観点では、以下のような「エラーの見え方」が変わる可能性があります:
- 以前:
succ内部のNoMethodErrorも「カーソルがsuccを実装していない」ように見えていた - 以後:
succ内部のNoMethodErrorは、そのままのエラー (例:undefined methodなど) として上がる
アプリケーションコードで UnadvanceableCursorError に依存したハンドリングをしている場合、
これまで「succ 内部バグも全部 UnadvanceableCursorError だった」前提のコードがあると、挙動が変わる可能性があります。
ただし、この変更の方が「メソッド契約どおり」であり、バグ調査もしやすくなるため、正しい方向の修正といえます。
- 参考情報 (あれば)
- 対象コード:
activejob/lib/active_job/continuation/step.rb - 想定している契約:
- 「
Step#advance!は、カーソルがsuccを実装していない場合にUnadvanceableCursorErrorを投げる」 succを実装している場合は、その実行中の例外はそのまま伝播させる
- 「
- コアな設計意図:
- 「API 契約違反 (succ 未実装)」と「実装バグ (succ 内部の例外)」を区別し、後者を隠さないようにすること
#57729 Pass the exception positionally when resuming a continuation after an error
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
Active Job の「継続可能(continuable)ジョブ」をエラー後に再開する際、例外オブジェクトの渡し方に不整合があり、exception_executionsのキーが意図しない文字列になる不具合を修正した PR です。例外は常に位置引数でresume_jobに渡すように統一し、それを検証するテストが追加されています。
- 変更内容の詳細
問題の背景
ActiveJob::Continuable#continue は、継続可能ジョブが途中まで進んだ後にエラーを投げた場合に、そのジョブを再開する責務を持ちます。内部ではおおよそ以下のような例外ハンドリングをしていました(PR 説明からの抜粋・整形):
rescue Continuation::Interrupt => e
resume_job(e) # 位置引数
rescue Continuation::Error
raise
rescue StandardError => e
if resume_errors_after_advancing? && continuation.advanced?
resume_job(exception: e) # キーワード風(実際にはハッシュ)
else
raise
end
end一方、resume_job の定義は次のようになっており、位置引数1つのみを受け取るメソッドです:
def resume_job(exception)
executions_for(exception)
...
endここで resume_job(exception: e) と呼んだ場合、Ruby では exception: e は キーワード引数ではなくハッシュリテラル { exception: e } と解釈され、それがそのまま位置引数 exception に渡されます。
結果として:
- 正常系(
Continuation::Interrupt経由)resume_job(e)→exceptionはStandardErrorなどの例外オブジェクト
- 標準エラー系(
StandardError経由)resume_job(exception: e)→exceptionは{ exception: e }というハッシュ
となり、resume_job 内部で使われる executions_for(exception) が異なる値を元にキーを生成してしまいます。
Active Job は exception_executions というハッシュに「特定のエラーが何回起きたか」を記録しますが、今回の挙動により、キーが以下のように不自然な文字列になっていました:
# before
{ "{exception: #<StandardError: Cursor error>}" => 1 }
# 本来期待される形 (Interrupt 経由のパスと同じキー)
{ "Cursor error" => 1 }つまり、
- Interrupt で再開した場合: 例外メッセージ(例:
"Cursor error")がキーになる - 標準エラーで再開した場合: ハッシュを
to_sした文字列("{exception: #<StandardError: Cursor error>}")がキーになる
という不整合が生じていました。
修正内容
PR の修正は非常にピンポイントで、resume_job 呼び出しを以下のように変更しています。
- resume_job(exception: e)
+ resume_job(e)これにより:
- すべての再開パス(
Continuation::Interruptと通常のStandardError)で - 例外オブジェクトが 同じく位置引数として
resume_jobに渡され executions_for(exception)が一貫して「例外オブジェクト」を受け取れる
ようになり、exception_executions のキーも、すべて exception.message (例外メッセージ)ベースで統一されます。
テストの追加
activejob/test/cases/continuation_test.rb に 13 行のテストが追加され、次のことを検証しています:
- 継続可能ジョブが途中まで進んだ後にエラーを発生させ
resume_errors_after_advancing?が有効で再開されるケースにおいてexception_executionsのキーが、例外メッセージ(Interrupt 経由のパスと同じ形式)で記録されること
これにより、修正が仕様として固定され、回 regress しにくくなっています。
- 影響範囲・注意点
影響範囲:
ActiveJob::Continuableを利用しているジョブで、途中で進捗を記録した後、StandardError系の例外で再開されるパスに影響します。- 特に、
exception_executionsの値(キーが何か)を見て独自にモニタリングしているコード、メトリクス集計、再試行制御をしているコードがある場合、キーの形式が変わるため注意が必要です。
挙動の変化:
- 以前:
StandardError由来の再開では、キーが"{exception: #<StandardError: Some message>}"のような文字列になっていた。 - 以後: Interrupt 由来と同様に、例外メッセージ(例:
"Some message")をキーとして扱うようになる。 - これにより、「同じエラーなのにパスによって別キーになる」という問題が解消され、再試行カウントなどが正しく集計されやすくなります。
- 以前:
後方互換性:
- メソッドシグネチャは元々位置引数 1 つのみだったため、API 的な破壊的変更はありません。
- すでにキースキーマを前提にしたストレージやダッシュボードがある場合は、キーの変化を考慮した移行(過去データとの付き合わせなど)が必要になる可能性があります。
- 参考情報 (あれば)
- 対象クラス:
ActiveJob::Continuable(activejob/lib/active_job/continuable.rb) - 関連概念:
exception_executions: 例外ごとの実行回数を管理しているハッシュresume_errors_after_advancing?: 進捗済みでもエラー後にジョブを再開するかどうかのフラグContinuation::Interrupt/Continuation::Error: 継続処理に関連する内部的な例外クラス
この PR は、実装の意図(resume_job の引数名が exception であること)と実際の呼び出しを一致させる、小さいながらも意味のあるバグ修正です。
#57728 Clear enqueue_error when re-enqueuing a job
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
Active Job の同一ジョブインスタンスを再キューイングした際、前回の失敗時のenqueue_errorが残り続けてしまう問題を修正し、再キューイング時にenqueue_errorをクリアするようにした PR です。これにより、ジョブが実際には正常にキューイングされたにもかかわらず、監視やログに例外が発生したかのように記録される不整合が解消されます。
- 変更内容の詳細
問題の背景
ActiveJob::Enqueuing#enqueue は毎回 successfully_enqueued をリセットしていますが、enqueue_error はリセットしていませんでした。
疑似コードイメージ:
def enqueue(options = {})
set(options)
self.successfully_enqueued = false
# self.enqueue_error は触っていない
raw_enqueue
...
endそのため、以下のようなケースで状態が矛盾していました。
job.enqueue # adapter が EnqueueError を raise -> false を返す
job.enqueue # 2回目は成功 -> job を返す
job.successfully_enqueued? # => true
job.enqueue_error # => #<ActiveJob::EnqueueError ...> (本来は nil であるべき)さらにこの矛盾は、ActiveJob::StructuredEventSubscriber#enqueue にも影響します。このクラスではイベントの exception を次のように決めています:
exception = event.payload[:exception_object] || job.enqueue_error2回目の enqueue が成功したケースでは event.payload[:exception_object] は nil になりますが、job.enqueue_error に前回の例外が残っているため、
- 実際には成功した enqueue に対して
exception_class/exception_messageがイベントとして発火されてしまう
という誤ったログ・メトリクスが出る状況になっていました。
修正内容
この PR では、enqueue 前に enqueue_error もクリアするようにしています。
def enqueue(options = {})
set(options)
self.successfully_enqueued = false
self.enqueue_error = nil # ← 新しく追加
raw_enqueue
...
endこれにより、
- 2回目の enqueue が成功した場合
successfully_enqueued?はtrueenqueue_errorはnil
となり、状態が一貫します。
テスト
新規テストでは、同一インスタンスに対して
- 1回目: adapter が EnqueueError を返すようにして enqueue を失敗させる
- 2回目: 成功するようにして enqueue する
という流れを再現し、
assert job.successfully_enqueued?
assert_nil job.enqueue_errorを検証しています。
- 影響範囲・注意点
影響範囲
ActiveJob::Enqueuing#enqueueを使っている全てのジョブクラスが対象ですが、- 変更は「enqueue 開始時に
enqueue_errorをnilにリセットする」だけです。
- 変更は「enqueue 開始時に
- 特に、同じジョブインスタンスを使い回して再キューイングするケース や、 キューイング失敗時のエラー情報を
enqueue_error経由で参照しているコード に影響します。 ActiveJob::StructuredEventSubscriberを使ったログ出力やメトリクス収集では、- これまで「成功した enqueue なのに例外付きでイベントが飛んでいた」ような誤検知が解消されます。
注意点
- 「直前の enqueue 失敗のエラーを、後続の enqueue 成否に関わらずジョブインスタンス内に残したい」という特殊な用途がもしあれば、そのユースケースには合わなくなります(しかし一般的には、ジョブの状態は最新の enqueue の結果を反映すべきなので、この変更が正しい挙動と言えます)。
- もしアプリ側で
enqueue_errorを参照しているコードがある場合は、- 「どの enqueue の結果を見たいのか」を明確にし、必要であれば独自にログや別フィールドに保存する実装が望まれます。
- 参考情報 (あれば)
- 関連クラス/メソッド
ActiveJob::Enqueuing#enqueueActiveJob::EnqueueErrorActiveJob::StructuredEventSubscriber#enqueue
- 想定ユースケース
- flaky な adapter(外部キューサービス一時障害など)に対して、同じジョブインスタンスを再利用してリトライするケース
- enqueue の成功/失敗を監視基盤(構造化ログ、APM など)に送っている環境でのノイズ削減
#57725 Include adapter in Active Job perform_start event payload
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
Active Job のperform_start構造化イベントのペイロードにadapter情報が含まれていなかった不整合な挙動を修正し、他のイベント(enqueue,enqueue_at,enqueue_all,perform)と同様にアダプタ名を含めるようにした PR です。これによりログや構造化イベントの利用時に、ジョブ実行開始時点でも正しいアダプタ名が参照できるようになります。
- 変更内容の詳細
問題の背景
Active Job には、構造化イベントを発行する ActiveJob::StructuredEventSubscriber があり、以下のようなイベントでペイロードに adapter キーを含めています。
enqueueenqueue_atenqueue_allperform(= 完了)
ところが perform_start だけが adapter キーをペイロードに含めておらず、結果として:
active_job.startedイベントにはadapterが無いactive_job.completedイベントにはadapterがある
という不整合な状態になっていました。
ActiveJob::LogSubscriber#started は、イベントペイロードから :adapter と :queue を読み取り、adapter(queue) という形の文字列に整形してログを出力しますが、perform_start 側のペイロードに adapter が無いせいで、開始ログだけアダプタ名が欠落していました。
挙動の例
Async アダプタでジョブを実行したときのログ:
Performing TestJob (Job ID: ...) from (default)
Performed TestJob (Job ID: ...) from Async(default) in 0.3ms期待されるのは以下のように両方にアダプタ名が含まれることです:
Performing TestJob (Job ID: ...) from Async(default)
Performed TestJob (Job ID: ...) from Async(default) in 0.3ms根本原因
内部的には、Active Job のインストルメンテーション層 (Instrumentation#instrument) がすでに payload[:adapter] = queue_adapter を設定しており、通知イベント自体には adapter が入っている状態です。
問題は ActiveJob::StructuredEventSubscriber#perform_start が、その event.payload[:adapter] を構造化イベント用のペイロードに転記していなかった点にあります。
具体的な修正内容
perform_start において、他のイベントと同様に adapter をペイロードに含めるよう修正されています。
変更後のイメージ:
def perform_start(event)
job = event.payload[:job]
adapter = event.payload[:adapter]
payload = {
job_class: job.class.name,
job_id: job.job_id,
queue: job.queue_name,
adapter: ActiveJob.adapter_name(adapter),
enqueued_at: job.enqueued_at&.utc&.iso8601(9),
}
# この payload を使って structured event を発行
endポイント:
event.payload[:adapter]をローカル変数adapterに取り出し、- 他イベント同様
ActiveJob.adapter_name(adapter)でアダプタ名へ正規化し、 payload[:adapter]に含めています。
テストの変更
activejob/test/cases/structured_event_subscriber_test.rb の test_perform_start_job を更新:
- 以前は
job_classとqueueしか検証しておらず、adapter欠落を検知できなかった。 - 本 PR により
perform_startイベントのペイロードにadapterが含まれていることを明示的に期待・検証するように変更。 - これで
enqueue/enqueue_atのテストと同等レベルのアサーションになり、再発防止になります。
テスト結果(ローカル実行例):
activejob/test/cases/structured_event_subscriber_test.rb -> 11 runs, 24 assertions, 0 failures
activejob/test/cases/logging_test.rb -> 39 runs, 186 assertions, 0 failures- 影響範囲・注意点
ログ出力の変化
active_job.started(=ジョブ実行開始)に対応するログ行が、これまでよりも情報リッチになります。- 具体的には
"from (default)"のようにアダプタ名なしだった部分が"from Async(default)"のようにアダプタ名付きになります。 - ログ文言をパースしているツールがある場合、
from <adapter>(<queue>)の形式を前提としていれば、今回の変更でより一貫性のある入力が得られます。
構造化イベント利用者への影響
active_job.startedイベントのペイロードにadapterキーが新たに追加されます。- すでに
active_job.enqueueなどの sibling イベントでadapterを利用している監視・メトリクス基盤であれば、startedに対しても同様の集計(アダプタ別のメトリクスなど)が可能になります。 adapterが増えるのは additive な変更であり、既存コードがadapterを前提としていない限り壊れることはありません(nil でなくなったことで問題が出るケースは基本的に考えづらい)。
リリース状態
- この構造化イベント /
EventReporter経由のログサブスクライバは、まだ正式リリースタグには入っていない部分とのことで、公開済みバージョン上の「回 regress」ではなく、そもそも初期実装時の欠落を修正した位置づけです。 - そのため CHANGELOG には記載されていません。
- この構造化イベント /
- 参考情報 (あれば)
- 対象クラス:
ActiveJob::StructuredEventSubscriberActiveJob::LogSubscriber(特に#started→queue_nameヘルパ)
- 関連イベント:
active_job.enqueueactive_job.enqueue_atactive_job.enqueue_allactive_job.started(今回修正対象)active_job.completed
- アダプタ名の解決:
ActiveJob.adapter_name(adapter)により、Async,Inline,Sidekiqなどの文字列表現に正規化されます。
#57688 Run Action Cable JS tests with Web Test Runner
マージ日: 2026/6/14 | 作成者: @matthewd
- 概要 (1-2文で)
Action Cable の JavaScript テスト実行環境を、非推奨となった Karma から Web Test Runner + Sauce Labs を使う構成に移行した PRです。既存の QUnit テストスイートを壊さずに、新しいテストランナー経由でブラウザテストを実行できるようにしています。
- 変更内容の詳細
2-1. Karma からの移行
actioncable/karma.conf.jsを削除 (中身 63 行すべて削除)- Karma ベースの設定一式を廃止
- これにより、Action Cable の JS テストは Karma 経由では動かなくなり、今後は Web Test Runner 経由のみを想定
actioncable/package.jsonの変更 (+6/-7)- 主な内容(推測を含む)
- devDependencies から
karma,karma-*関連パッケージを削除 - 代わりに
@web/test-runner,@web/test-runner-sauce等を追加 - テストスクリプトを Karma から Web Test Runner に切り替え
例(イメージ):jsonc{ "scripts": { // 旧: // "test": "karma start karma.conf.js" // 新: "test": "node test/javascript/run_web_test_runner.mjs" } } - QUnit 自体は現行のテストスイートを使い回すため、そこは温存。Web Test Runner 経由で QUnit を動かすアダプタ構成に。
- devDependencies から
- 主な内容(推測を含む)
2-2. Web Test Runner 実行スクリプトの追加
actioncable/test/javascript/run_web_test_runner.mjs(+26)- Web Test Runner を起動するためのエントリポイントスクリプト
- 概ね以下のようなことを行う構成が想定されます:
web-test-runner.config.mjsを読み込んで設定を適用- CI かローカルかなどの条件に応じて、起動オプションを調整
- Node からプログラム的に
@web/test-runnerを実行し、終了コードをそのままプロセスの exit code に反映
- これにより、
yarn testや CI の job から統一的に Web Test Runner を叩けるようになる
簡易イメージ(あくまで参考):
// test/javascript/run_web_test_runner.mjs
import { startTestRunner } from '@web/test-runner';
import config from '../../web-test-runner.config.mjs';
const result = await startTestRunner({ config });
process.exit(result.failed ? 1 : 0);2-3. Sauce Labs 連携の設定
actioncable/test/javascript/sauce_labs.mjs(+278)- Sauce Labs 上でブラウザテストを実行するための設定・ヘルパーロジック一式
- 主な内容(推測を含む):
- Sauce Labs のブラウザ環境定義(Chrome / Firefox / Safari / Edge 等のバージョン行列)
- 環境変数 (
SAUCE_USERNAME,SAUCE_ACCESS_KEYなど) から認証情報を取得 - CI ブランチ・PR かどうかに応じて、テスト対象ブラウザや並列度を切り替え
- Web Test Runner に渡す Launcher 設定 (SauceLauncher 的なもの) の生成
- Karma 時代に使っていた Sauce 連携ロジックがあれば、それの Web Test Runner 版に相当
イメージコード(抜粋イメージ):
// test/javascript/sauce_labs.mjs
import { sauceLauncher } from '@web/test-runner-sauce';
export function createSauceLaunchers() {
return {
chromeLatest: sauceLauncher({
browserName: 'chrome',
platformName: 'Windows 11',
browserVersion: 'latest',
// ...
}),
// 他ブラウザ...
};
}2-4. Web Test Runner の設定
actioncable/web-test-runner.config.mjs(+65)- Web Test Runner のメイン設定ファイル
- 主な要素(推測を含む):
- テスト対象ファイルパターン(例:
test/javascript/**/*_test.js) - QUnit を使うためのブラウザ側インポート・セットアップ
- ローカル実行時は
puppeteeror Playwright ベースの headless Chrome/Firefox を起動 - CI で Sauce Labs を使う場合は
sauce_labs.mjsから Launcher を読み込み、browsersに指定 - タイムアウト、並列実行数、カバレッジ設定など
- テスト対象ファイルパターン(例:
例イメージ:
// web-test-runner.config.mjs
import { playwrightLauncher } from '@web/test-runner-playwright';
import { createSauceLaunchers } from './test/javascript/sauce_labs.mjs';
const isCI = process.env.CI === 'true';
export default {
files: 'test/javascript/**/*_test.js',
nodeResolve: true,
browsers: isCI
? Object.values(createSauceLaunchers())
: [playwrightLauncher({ product: 'chromium' })],
testFramework: {
config: {
ui: 'qunit',
},
},
};2-5. 依存関係 (yarn.lock) の大幅更新
yarn.lock(+3109/-840)- Karma 関連ライブラリの削除、新規に導入した Web Test Runner + Sauce 関連ライブラリのロックが大量に追加
- これに伴い transitive dependencies も大きく変動
- ランナーやブラウザドライバ関連のパッケージが追加されている想定
- 影響範囲・注意点
テスト実行コマンドの変更
- これまで
yarn test等で Karma を前提としていた場合、今後は Web Test Runner が使われます。 - 開発者ローカルで Action Cable の JS テストを回すときは、PR の変更後の
package.jsonに書かれている新しいコマンドを確認する必要があります。
- これまで
CI 設定の更新必須
- CI で
karma start karma.conf.jsを呼んでいたジョブは失敗するため、node actioncable/test/javascript/run_web_test_runner.mjs- または
yarn test(PR で定義された新しい script)
に切り替える必要があります。
- また、Sauce Labs 用の環境変数 (ユーザ名・アクセストークンなど) が CI 上で適切に設定されていることが前提です。
- CI で
テストの見た目と挙動が変わる可能性
- テストランナーの UI / ログ形式が Karma から Web Test Runner に変わるので、
- ログの読み方
- テスト失敗時のスタックトレースのフォーマット
- ブラウザコンソール出力の扱い
などが変わる可能性があります。
- ただしテスト本体は QUnit のままのため、テストコードの API レベルの変更はほぼありません。
- テストランナーの UI / ログ形式が Karma から Web Test Runner に変わるので、
フレークテスト調査の前段階
- PR 説明にもある通り、「既存 CI ジョブで発生しているフレーク (flaky) な失敗調査をする前に、まずはサポートされているツールチェーンに乗せ換える」ことが目的。
- そのため、この PR マージ後もしばらくはテストが不安定な可能性は残っており、今後の PR で flakiness 改善が図られる想定です。
ローカル環境差異
- ローカルでブラウザテストを実行する際に、Node / npm / yarn のバージョンや OS の違いで動作が変わる可能性があります。
- 新しく導入された Web Test Runner / Playwright / Sauce 関連依存が Node のサポートバージョンを制限する場合があるため、開発環境の Node バージョンは(Rails リポジトリの推奨に)合わせておく方が安全です。
- 参考情報 (あれば)
- PR 本文で引用されている通り、Karma は現在非推奨:
- Web Test Runner:
- Sauce Labs 用 Web Test Runner Launcher:
この PR は「テストランナーの世代交代」が主目的で、テスト内容そのものを大きく変えないまま、Action Cable の JS テストを将来もメンテ可能な基盤に載せ替える変更と位置付けられます。
#57687 Assortment of CI/test fixes
マージ日: 2026/6/14 | 作成者: @matthewd
- 概要 (1-2文で)
CI とテスト周りの安定性を向上させるための修正がまとめて行われ、特に Active Record のコネクションプール切断時の例外ハンドリングが「例外を報告して処理は継続する」挙動に変更されました。その他は主にテストコード側の調整・リファクタリングで、本体 API の互換性を壊す変更は原則ありません。
- 変更内容の詳細
2-1. connection_pool 切断時の例外ハンドリング (lib 側唯一の本体変更)
対象:activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb
これまで、プールの disconnect 中に発生した例外は、そのままテストや呼び出し元に伝播して CI を落とす原因になりうる状態でした。
この PR では、**「例外をログ等に report した上で、プールの切断処理自体は継続する」**方針に変更されています。
概念的には次のようなイメージです(擬似コード):
def disconnect!
with_connection do |conn|
# 以前はここで raise されると全体が中断していた
conn.disconnect!
rescue => e
# 今回の変更: フレームワークとして例外を報告しつつ、処理は続行
ActiveSupport::Notifications.instrument("connection_pool_disconnect.error", error: e)
# あるいは logger.error などで報告し、プールのクリーンアップは続ける
end
endPR の説明文にある通り、「プールを明示的に切断する」というのはサポートされているフレームワークの挙動/ API だが、実際には Rails 自身のテストスイートが最大の利用者の一つという前提があり、テストの安定性を重視した変更といえます。
期待される効果:
- 切断処理中の一時的な例外(接続先 DB が既に落ちている等)が、テスト全体を不安定にしない
- 呼び出し側は「切断要求はベストエフォートで実行され、失敗はログまたは通知経由で観測する」スタイルになる
2-2. 各アダプタのテスト修正
対象:
activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rbactiverecord/test/cases/adapters/trilogy/trilogy_adapter_test.rbactiverecord/test/support/adapter_helper.rb
主に CI での不安定要因を減らすための修正が含まれていると考えられます。
具体的には:
- 切断・再接続・トランザクション周りで、DB 状態に依存してフレークしやすいテストの条件や期待値の調整
- Trilogy / PostgreSQL 固有の挙動に合わせて、アサーションや前提条件を整理
- テストヘルパの設定値(接続情報やスキップ条件)の微修正
これらにより、異なる環境/DB バージョンでも一貫した成功を得やすくなります。
2-3. connection_pool_test の拡充・修正
対象:activerecord/test/cases/connection_pool_test.rb (+94/-30)
今回の本体変更に直結する部分で、テストがかなり増えています。
想定される内容:
- 切断時に例外を発生させるダミー接続オブジェクトを用意して、
- 例外が「報告される」こと
- それでもプールのクリーンアップ処理が継続すること
を検証するテスト
- 複数の接続を持つプールに対して、一部の切断のみが失敗しても、他の接続やプールオブジェクトの状態が破綻しないことの確認
- マルチスレッド/並列環境での切断時挙動に関する安定性テスト
このファイルが最も大きく増えており、「report-and-continue」ポリシーの仕様をテストで明示した位置づけになっています。
2-4. fixtures / relation / strict_loading / transactions 関連テストの調整
対象:
activerecord/test/cases/fixtures_test.rbactiverecord/test/cases/relation/or_test.rbactiverecord/test/cases/strict_loading_test.rbactiverecord/test/cases/transactions_test.rbactiverecord/test/fixtures/pirates.yml
主に以下のような細かいチューニングが想定されます:
- テストデータ (
pirates.yml) の調整:- テストケースに必要なフィールド値の修正
- DB 制約や strict loading 仕様の変更/明確化にあわせて意図した状態に揃える
transactions_testの大幅な整理 (+21/-53):- 不安定なアサーションの削除・簡略化
- DB アダプタ差異を吸収した期待値の見直し
- 「トランザクション中に例外が出た場合」の挙動テストを、現在の実装と一致するように修正
strict_loading_test/relation/or_test:- strict loading や
orクエリの仕様が変わったわけではなく、 - テスト側の前提(事前ロード・関連の有無など)を現行挙動に沿うように修正
- strict loading や
これらは基本的に テストが実際の挙動とズレていた部分を修正するものであり、API 側の破壊的変更を伴うものではないと見てよいです。
2-5. Active Support / Railties / Guides まわりのテスト修正
対象:
activesupport/test/evented_file_update_checker_test.rb(+9/-2)guides/test/epub_test.rb(+0/-3)railties/test/isolation/abstract_unit.rb(+7/-2)railties/test/isolation/test_helpers_test.rb(+22/-0)
想定される変更点:
evented_file_update_checker_test.rb:- OS ファイルシステム差異やタイミング依存で落ちやすいテストに対し、待ち時間やイベント検知条件を調整
- CI 環境での flakiness を減らす目的
- Guides の
epub_test.rb:- CI でサードパーティツールや外部依存が原因で失敗しないように、テストケースやアサーションを簡略化
- Railties の isolation テスト:
abstract_unit.rbとtest_helpers_test.rbで、テストアプリケーションの起動・終了や環境変数/ロードパス操作をより安全に行うためのヘルパ強化- テストプロセス間の汚染(環境変数、グローバル状態など)が他のテストに波及しないようにする
これらはすべて「CI が安定して通るようにするためのテスト基盤側の改善」です。
- 影響範囲・注意点
- 本体 API への主な影響は connection_pool の切断時挙動のみ
- disconnect 系メソッド呼び出しで例外が起きた場合、今後は「例外がログ/通知されつつ、処理は続行」されることが前提になります。
- 以前、disconnect での例外を rescue して独自処理していたコードは、「そもそも例外が上がってこない」可能性があるため、挙動を確認してください。
- とはいえ、この API の強い利用者は Rails 自身のテストスイートであり、アプリケーションコードで直接
connection_poolを操作しているケースは多くありません。
通常のアプリケーションでは、実質的な挙動差はほぼ感じないはずです。 - テストコードが多く変更されていますが、いずれも 既存の実装に合わせてテストを直したり、CI の不安定さを解消したりするもので、アプリケーションコード側の対応は原則不要です。
- 参考情報 (あれば)
- PR 本文の要点:
- 「
libの変更は 1 点のみで、AR プール切断中の例外は report-and-continue にした」 - 「この API 自体は公式な挙動だが、最大の利用者はおそらく Rails のテストスイート自身」
- 「
- もし自前で connection pool を直接扱っている場合は、
ActiveRecord::Base.connection_pool.disconnect!/clear_active_connections!/clear_all_connections!周りの挙動がどう変わるかを、一度ログ出力などで確認すると安心です。
#57723 Fix retry_on wait: proc with an optional argument
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
ActiveJob.retry_onのwait:オプションに渡すProcが「省略可能な引数1つ」を取る場合(->(executions = 0) { ... }など)にArgumentErrorが発生していた不具合を修正する PR です。Proc#arityの扱いを見直し、1引数・可変長引数・2引数の各パターンで後方互換性を保ちながら適切な呼び出しが行われるようになりました。
- 変更内容の詳細
背景となる仕様
ActiveJob の retry_on は、再実行時の待機時間を wait: オプションで指定できます:
retry_on SomeError, wait: ->(executions) { executions * 2 }この wait: には以下の2系統のプロックがサポートされています:
1引数:
->(executions) { ... }executions: 何回目の実行か(1回目=1, 2回目=2...)
2引数:
->(executions, error) { ... }executions: 上と同じerror: 発生した例外オブジェクト
もともとの実装では、Proc#arity を使って「1引数なのか、2引数(以上)なのか」を判定していました。
既存の問題点
元コード(説明文中の旧実装)はおおよそ次のようなロジックでした:
if algorithm.arity == 1
algorithm.call(executions)
else
algorithm.call(executions, error)
endRuby の Proc#arity は以下のような振る舞いをします:
->(x) {}# arity == 1->(x, y) {}# arity == 2->(x = 0) {}# arity == -1 (「必須引数1個、オプションあり」)->(x, *rest) {}# arity == -2 (「必須引数1個+可変長」)
そのため、->(executions = 0) { ... } のような「オプション引数1つ」の待機時間プロックを渡すと arity == -1 になり、else 側で
algorithm.call(executions, error)と 2引数で呼ばれてしまい ArgumentError になる というバグがありました。
修正内容
新しいロジックでは、「その callable が 2つ目の引数を安全に受け取れるか」を基準に判断するように変更されています:
if algorithm.arity == 2 || algorithm.arity < -1
algorithm.call(executions, error)
else
algorithm.call(executions)
endこの判定が意味するところは以下です:
algorithm.arity == 2
→ 「引数2つを必須で取る」:->(executions, error) { ... }のようなケース
→executions, errorを渡して呼ぶalgorithm.arity < -1
→ 「可変長引数を持ち、2つ以上の引数を受けられる」: 例->(executions, *rest) { ... }# arity == -2->(*args) { ... }# arity == -1 ではなく -n のパターン
→executions, errorで呼んでも許容される
上記以外(
arity == 1またはarity == -1)->(executions) { ... }# 1->(executions = 0) { ... }# -1(今回問題になっていた形) → 1つ目の引数のみを渡す (executionsのみ)
結果として、説明にあった通り次のような対応になります:
->(executions)(1) と->(executions = 0)(-1) → 1引数で呼び出す->(executions, error)(2) と->(executions, *rest)(-2) → 2引数で呼び出す
これにより、「もともと1引数のつもりで書いたが、デフォルト値を付けたくなった(->(executions = 0))というコード」が、従来通り 1引数の扱い をされるようになります。
テストの追加
以下のテストが追加されています:
activejob/test/jobs/retry_job.rbジョブに次のような宣言を追加:
rubyretry_on SomeError, wait: ->(executions = 0) { executions * 2 }
activejob/test/cases/exceptions_test.rb- 上記のジョブについて、
AJ_ADAPTER=test環境で
「期待したスケジュールでリトライされるか」を検証するテストを追加。
- 上記のジョブについて、
このテストは、修正前はリトライ中に ArgumentError が発生してレッドになり、修正後はグリーンになることが確認されています。既存の 1 引数・2 引数 wait: プロックのテストも引き続きグリーンで、後方互換性も保たれています。
- 影響範囲・注意点
影響範囲
- ActiveJob の
retry_on/discard_on(exceptions.rbで使われる同系のロジック)におけるwait:プロックの呼び出しに限定されます。 wait:に- オプション引数1つ (
->(executions = 0) { ... }) - 可変長引数 (
->(executions, *rest) { ... }など) を使っている場合に挙動が変わる(あるいは初めて期待通りに動く)可能性があります。
- オプション引数1つ (
- ActiveJob の
後方互換性
- 既存の以下のパターンはそのまま動作します:
->(executions) { ... }… 1引数として呼ばれる->(executions, error) { ... }… 2引数として呼ばれる
- 「今までたまたま動いていた変なケース」があるとすれば、例えば
->(*args) { ... }など、引数を何でも受け入れるような形で、arityが -1 未満になるケースでも 2 引数で呼ばれます。
通常の使い方では問題にならないはずですが、もしwait:で*argsを前提とした特殊な処理をしている場合は、一応確認すると安心です。
- 既存の以下のパターンはそのまま動作します:
注意点
wait:プロックの設計としては:- 「エラーに依存しない」待機時間 → 1引数 or オプション1引数で
->(executions = 0) { ... } - 「発生した例外に応じて変えたい」待機時間 → 2引数以上で
->(executions, error) { ... }と明示的に書き分けるのがよいです。
- 「エラーに依存しない」待機時間 → 1引数 or オプション1引数で
Proc#arityの仕様に依存した分岐であるため、「より複雑なシグネチャ(キーワード引数など)」をwait:に与えるのは避ける方が無難です(現状サポート範囲外と考えた方がよい)。
- 参考情報 (あれば)
- 該当コード:
activejob/lib/active_job/exceptions.rbretry_on/discard_onの内部でwait:オプションを解釈している部分の 1 行のみが変更されています。 - Ruby の
Proc#arity仕様(ざっくり):- 正の値: 必須引数の数
- 0: 引数なし
- 負の値:
-(必須引数 + 1)
例:->(x = 0) {}# -1 (必須1 + 可変/オプション)->(x, *rest) {}# -2 (必須1 + 可変)
- この PR はこの仕様を踏まえて「2つ目の引数を安全に渡せるかどうか」を判定しています。
#57715 Exclude all composite primary key components from content_columns
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
content_columnsが複合主キー(composite primary key, CPK)のカラムを正しく除外できていなかった問題を修正し、CPK の全ての主キー構成カラムを確実に除外するようにしました。単一主キーの場合の挙動・ドキュメントと一貫した動作になります。
- 変更内容の詳細
何が問題だったか
元の実装:
def content_columns
@content_columns ||= columns.reject do |c|
c.name == primary_key ||
c.name == inheritance_column ||
c.name.end_with?("_id", "_count")
end.freeze
end単一主キーの場合
primary_key # => "id"なので、c.name == primary_keyで"id"カラムは除外される。複合主キーの場合
primary_key # => ["author_id", "id"]のように Array を返すため、rubyc.name == primary_key # String == Array → 常に falseとなり、主キーのどのカラムもこの条件では除外されない。
たまたま動いていたケース
author_idは「_idで終わる」のでc.name.end_with?("_id", "_count")で除外される。- しかし
"id"は_idで終わらないため、どの条件にもマッチせず残ってしまう。
その結果:
Cpk::Book.primary_key # => ["author_id", "id"]
Cpk::Book.content_columns.map(&:name)
# => ["id", "title", "revision"] # 本来は PK を除いた ["title", "revision"] が期待ドキュメント上も「primary id は除外される」とされているため、仕様に反する挙動になっていました。
どのように直したか
primary_key を常に Array として扱う Rails 既存のイディオムに合わせて修正:
# 修正後の条件部分イメージ
Array(primary_key).include?(c.name) ||
c.name == inheritance_column ||
c.name.end_with?("_id", "_count")ポイント:
Array(primary_key)は以下のようにふるまう:- 単一主キー:
primary_key == "id"→Array("id") # => ["id"] - 複合主キー:
primary_key == ["author_id", "id"]→ そのまま["author_id", "id"] - 主キーなし:
primary_key == nil→Array(nil) # => []
- 単一主キー:
- これにより:
- CPK の全ての構成カラムが
include?によって確実に除外される - 単一主キーでも
"id"が正しく除外され続ける - 主キーなしモデルでは PK による除外条件は空配列になり、これまで通り「常に false」で影響なし
- CPK の全ての構成カラムが
テスト
Cpk::Book(primary key[:author_id, :id]) に対して:rubyとなることをCpk::Book.content_columns.map(&:name) # 修正前: ["id", "title", "revision"] # 修正後: ["title", "revision"]test/cases/base_test.rbで検証(PR 説明中)。- 既存の単一主キー (
Topic) に対するcontent_columnsのテストはそのままグリーン。
コード変更自体は:
activerecord/lib/active_record/model_schema.rbの条件式をc.name == primary_key→Array(primary_key).include?(c.name)に 1行差し替え- テスト (
activerecord/test/cases/reflection_test.rb) に CPK ケースを追加 (+5 行)
- 影響範囲・注意点
影響する箇所:
ActiveRecord::Base#content_columnsを利用しているコード全般- 管理画面ジェネレータや scaffold 系コード
content_columnsベースで「入力フォーム用カラム」や「表示カラム」を自動列挙しているような実装
具体的な挙動変化:
- 複合主キーのモデルのみ 影響を受ける。
- 以前: CPK のうち、
_idや_countで終わらない主キー(例:"id","uuid"など)がcontent_columnsに含まれてしまうことがあった。 - 変更後: それらも 全て
content_columnsから除外される。
- 以前: CPK のうち、
- 単一主キー / 主キーなしモデルの挙動は、論理的には従来仕様と同等。
- 複合主キーのモデルのみ 影響を受ける。
注意点:
- もし既存アプリケーションで「
content_columnsに主キーの一部が含まれていたこと」を前提にしていた場合(特に CPK モデル)、この PR 適用後にそのカラムが取れなくなります。- 例:
content_columnsを使って「リソースの ID も含めた表示カラム一覧」を作っていたようなケース。
- 例:
- CPK を使っていて、
content_columnsを利用している箇所がないか確認すると安全です。
- もし既存アプリケーションで「
- 参考情報 (あれば)
- この PR で使われている
Array(primary_key)は、以下のような CPK 対応箇所でも使われている既存パターン:activerecord/lib/active_record/model_schema.rbactiverecord/lib/active_record/persistence.rbactiverecord/lib/active_record/relation/batches.rbactiverecord/lib/active_record/relation/calculations.rb
- 複合主キーを使う際に、
primary_keyを直接文字列前提で比較せず、Array(primary_key)経由で扱うのが Rails 内部実装の標準的な流儀になっています。
#57714 Treat remove_column with options but no type as irreversible
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
remove_columnに「型なし+オプションあり」で書かれたマイグレーションが、これまで「可逆」と誤判定されて壊れた逆操作 (add_column) を生成していた問題を修正し、正しくIrreversibleMigrationを投げるようにした PR です。これによりロールバック時の謎の例外ではなく、明示的な「型がないので不可逆」というエラーメッセージが得られるようになります。
- 変更内容の詳細
何が問題だったか
remove_column の逆操作は CommandRecorder で定義されていますが、その可逆性チェックが不完全でした。
元の実装:
def invert_remove_column(args)
raise ActiveRecord::IrreversibleMigration, "remove_column is only reversible if given a type." if args.size <= 2
super
endargs は記録された remove_column の引数配列です。
- 正しく可逆なケース:ruby
remove_column :users, :name, :string, null: false # 記録される args: [:users, :name, :string, { null: false }] # args.size == 4 → ガードを通過(OK) - 問題になるケース:ruby
remove_column :users, :name, null: false # 記録される args: [:users, :name, { null: false }] # args.size == 3 → 「型がある」と誤解してガードを通過してしまう
この場合 super(親実装)が逆操作を次のように組み立てます:
[:add_column, [:users, :name, { null: false }], block]
# table name "type" options と解釈されてしまうつまり「本来は :string のような型が来るべき位置に options ハッシュが入り込む」ため、ロールバック時の add_column 呼び出しが壊れます。
結果として、ロールバック時には本来出したかった
remove_column is only reversible if given a type.
ではなく、以下のような意味不明な例外が発生していました。
- 位置引数とキーワード引数の扱いによる
ArgumentError: wrong number of arguments (given 2, expected 3) - あるいは
NoMethodError: undefined method 'to_sym' for an instance of Hash
修正内容
兄弟メソッドである invert_remove_columns は、既に「型の有無」をより厳密に判定しており、
args[-1].is_a?(Hash) && args[-1].has_key?(:type)という形で「最後の引数が Hash で、その中の :type キーで型が指定されている」ケースのみを「型あり」とみなしていました。
今回の修正では、invert_remove_column も同様の方針に寄せ、
def invert_remove_column(args)
if args.size <= 2 || args[2].is_a?(Hash)
raise ActiveRecord::IrreversibleMigration, "remove_column is only reversible if given a type."
end
super
endと変更しています。
ポイント:
args.size <= 2
→ 旧来どおり、そもそも 3 つ目の引数(型に相当)が無い場合は不可逆。args[2].is_a?(Hash)
→ 3 つ目が Hash(= options)であり、実際の型が渡されていない場合も不可逆として扱う。
つまりremove_column :users, :name, null: falseも不可逆。
これにより:
- 可逆な例:ruby
remove_column :users, :name, :string, null: false # args: [:users, :name, :string, { null: false }] # args[2] == :string(Hash ではない)→ super へ → add_column :users, :name, :string, null: false - 不可逆な例:ruby
remove_column :users, :name # args: [:users, :name] → size <= 2 → IrreversibleMigration remove_column :users, :name, null: false # args: [:users, :name, { null: false }] → args[2].is_a?(Hash) → IrreversibleMigration
テスト
activerecord/test/cases/migration/command_recorder_test.rb にテストを追加:
inverse_of(:remove_column, [:table, :column, { null: false }])
# が IrreversibleMigration を raise することを確認- 修正前: 例外が出ず、壊れた
add_columnが生成される → 実行時にクラッシュ - 修正後: 期待どおり
IrreversibleMigrationが発生
既存のテスト(型あり・完全に引数不足のケース)はそのままグリーン。
- 影響範囲・注意点
影響を受けるのは「
changeマイグレーションでremove_columnを使い、型を省略したがオプションは渡していた」ケースです:rubydef change remove_column :users, :name, null: false # これまで「可逆」と誤判定されていた endこのようなマイグレーションは、これまではロールバック時に
- 「よく分からない
ArgumentErrorやNoMethodError」でコケていたものが、 - 今後はマイグレーション起動直後に
ActiveRecord::IrreversibleMigrationで明示的に止まる
ようになります。
- 「よく分からない
対応策としては、明示的に型を指定するか、
up/downに分けて書く必要があります:ruby# 可逆にしたい場合 def change remove_column :users, :name, :string, null: false end # 不可逆前提なら def up remove_column :users, :name, null: false end def down add_column :users, :name, :string, null: false end既に本番に適用済みのマイグレーションでこのパターンがある場合:
- 以前から実質ロールバック不能だったものが、「はっきりそう表示される」ようになっただけとも言えるため、挙動としては安全側への変更です。
- ただし「今まではたまたまロールバックしなかった環境で、今回初めてロールバックを試みる」といった場合に、
IrreversibleMigrationのエラーに気付くことになります。
- 参考情報 (あれば)
- 修正対象クラス:
activerecord/lib/active_record/migration/command_recorder.rb - コントラクトのドキュメント:
command_recorder.rbにremove_column (must supply a type)と明記されており、この PR はその仕様に実装を揃えるものです。 - 類似ロジック: 複数カラム削除用の
invert_remove_columnsは既に:typeキーの有無で判定しており、今回の変更でinvert_remove_columnも同じ思想に揃いました。
#57712 Fix counter_cache_column crash when no column is given on the has_many side
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
Rails 7.2系で、has_many/has_one側にcounter_cache: trueやcounter_cache: { active: false }を指定した際にNoMethodError: undefined method '-@' for nilで落ちるリグレッションが修正されました。カウンターキャッシュ列が省略された場合でも、従来どおり自動でxxx_countというカラム名が使われるようになります。
- 変更内容の詳細
問題のあったコード
has_many / has_one のリフレクションでカウンターキャッシュ列名を決定するメソッド counter_cache_column が、次のようになっていました:
-((counter_cache && -counter_cache[:column]) || "#{name}_count")一方で、関連定義のオプションは normalize_options で以下のように正規化されます:
# reflection.rb#normalize_options
when Hash
active = counter_cache.fetch(:active, true)
column = counter_cache[:column]&.to_s # カラム未指定時は nil
end
options[:counter_cache] = { active: active, column: column }この結果:
counter_cache: truecounter_cache: { active: false }
のようにカラム名を明示しない場合、options[:counter_cache] は { active: true/false, column: nil } になります。
Ruby では Hash は常に truthy なので、
counter_cache && -counter_cache[:column]は必ず -counter_cache[:column] を評価しようとし、counter_cache[:column] が nil のときに -nil が実行されて NoMethodError になります。この例外は || "#{name}_count" に到達する前に発生するため、"books_count" などのデフォルト名にフォールバックできませんでした。
belongs_to 側では同様の処理が正しく書かれており、単項マイナスをカラム名そのものにはかけていません:
counter_cache[:column] || -"#{active_record.name.demodulize.underscore.pluralize}_count"ここでの単項マイナス -@ は「文字列を freeze する」ための Active Support の仕様です(-"foo" → "foo".freeze)。
修正内容
has_many / has_one 側の実装から「余計な内側の -」を取り除きました:
# 修正前
-((counter_cache && -counter_cache[:column]) || "#{name}_count")
# 修正後
-((counter_cache && counter_cache[:column]) || "#{name}_count")これにより:
counter_cache[:column]が nil なら|| "#{name}_count"にフォールバックし、- 最終結果に対してだけ単項マイナス(freeze)をかける、
という belongs_to 側と整合した動作になります。
テスト追加
activerecord/test/cases/reflection_test.rb に以下のパターンを検証するテストが追加されています:
has_manyにcounter_cache: trueを指定した場合 →"books_count"を返すことhas_manyにcounter_cache: { active: false }を指定した場合 →"books_count"を返すこと
修正前はどちらも NoMethodError で落ち、修正後にパスすることが確認されています。既存の reflection / belongs_to のカウンターキャッシュ関連テストはすべてグリーンのままです。
- 影響範囲・注意点
- 影響を受けるバージョン
- リグレッションはコミット
e79455f3d4(「バックフィル中に counter cache カラムを無視する機能の追加」)以降、Rails 7.2.0 から発生しています。
- リグレッションはコミット
- 影響を受けるコードパターン
has_many/has_one側で次のように宣言しているケース:rubyhas_many :books, counter_cache: true has_many :books, counter_cache: { active: false } # 推奨されている安全なバックフィル形- これらの関連に対して:
author.books.create!author.books.size- その他、
HasManyAssociation#update_counter_in_memoryを経由する操作 がNoMethodError: undefined method '-@' for nilで落ちる可能性がありました。
- 影響を受けないパターン
counter_cache: :custom_nameのように明示的にカラム名を指定している場合は、counter_cache[:column]がnilではないため、問題は発生しません。belongs_to側でのcounter_cache設定も、本件のバグの影響は受けません(実装が別で、もともと正しくフォールバックする)。
- 実務上の注意点
- Rails 7.2.0 〜 7.2.x を使っていて、
has_many/has_oneにcounter_cache: trueやcounter_cache: { active: false }を書いている場合は、この修正が入ったバージョンへのアップデートで例外が解消されます。 - 一時的なワークアラウンドとしては、
counter_cache: trueの代わりに明示的なカラム名を指定することでも回避できます:rubyただし、PRの修正が取り込まれたバージョンにアップデートできるなら、それが一番シンプルです。has_many :books, counter_cache: :books_count has_many :books, counter_cache: { active: false, column: :books_count }
- Rails 7.2.0 〜 7.2.x を使っていて、
- 参考情報 (あれば)
- 該当コミット(リグレッション導入元):
e79455f3d4– 「Add the ability to ignore counter cache columns while they are backfilling」 - 関連クラス / メソッド:
ActiveRecord::Reflection::AssociationReflection#counter_cache_columnActiveRecord::Reflection::AssociationReflection#normalize_optionsActiveRecord::Associations::HasManyAssociation#update_counter_in_memory
-"string"という書き方は、Active Support によるString#-@の拡張で「文字列を freeze する」省略記法であり、本PRではその freeze の振る舞い自体は維持したまま、nil ハンドリングのみを修正しています。
#57708 Clear the connections registry on server restart
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
このPRは、Action Cable のサーバ再起動 (Server::Base#restart) 時に接続レジストリ(connections_map)がクリアされず、クローズ済み接続オブジェクトが蓄積してしまう不具合を修正します。再起動処理の中でconnections_map.clearを実行することで、開発モードなどでの繰り返しリロード時に「死んだ接続」が溜まらないようにしています。
- 変更内容の詳細
問題の構造
現状の Server::Base#restart は以下の流れです(要点のみ):
def restart
each_connection do |connection|
connection.close(reason: ...)
end
@mutex.synchronize do
@heartbeat_timer.shutdown if @heartbeat_timer
@heartbeat_timer = nil
@worker_pool.halt if @worker_pool
@worker_pool = nil
# executor, pubsub なども tear down
end
endeach_connectionで全接続に対してconnection.closeを呼ぶ。- 各接続は
Socket#handle_close内のserver.remove_connectionによってconnections_mapから削除される設計。 - しかし
handle_closeはワーカープール上で非同期にsend_async :handle_closeされる。 restartはそのワーカープールを@worker_pool.haltで停止してしまうため、キューに溜まっていたremove_connectionコールバックが実行されない。- 結果として、既に close 済みの接続オブジェクトが
connections_mapに残り続ける。
このため:
#connectionsopen_connections_statistics
といったメソッドが、実際にはクローズ済みの接続を数え続け、かつオブジェクトを保持し続ける(リーク)状態になっていました。
特に問題になるのが開発モードのリロード:
engine.rbのbefore_class_unloadで、クラスアンロード前に毎回ActionCable.server.restartが呼ばれる。- そのたびにクローズ済み接続が
connections_mapに残り、開発セッションが長くなるほど不要な接続オブジェクトが蓄積する。
先行する #57700 でハートビートタイマーの tear down ギャップが修正されましたが、connections_map のクリアが抜けていた、という位置づけです。
修正内容
restart の tear down セクション(@mutex.synchronize ブロック内)で、connections_map を明示的にクリアするようにしました。
イメージとしては以下のような変更です:
@mutex.synchronize do
# Drop the closed connections. remove_connection normally runs on the
# worker pool, which we halt below, so the entries would otherwise leak.
connections_map.clear
@heartbeat_timer.shutdown if @heartbeat_timer
@heartbeat_timer = nil
@worker_pool.halt if @worker_pool
@worker_pool = nil
# ...executor, pubsub など既存のリセット処理...
endポイント:
connections_map.clearは@mutex下で呼ばれており、他のリソースの tear down と同じクリティカルセクションに収まっている。connections_map.delete/clearは冪等であるため、たまたま一部のhandle_closeがワーカープール停止前に走ってremove_connectionしていても、clearと競合しない(実害がない)。
テスト
actioncable/test/server/base_test.rb にテストが追加されています:
- 手順イメージ:
- サーバに接続を 1 件追加。
server.restartを呼び出す。server.connectionsが空であることをアサート。
- 修正前は、クローズ済み接続が
connections_mapに残っているためテストが失敗(red)。 - 修正後は、
connections_map.clearによりconnectionsが空になりテスト成功(green)。 - 既存の
base_test.rbのテスト群にも影響はなく、すべて green。
- 影響範囲・注意点
- 対象コンポーネント:
ActionCable::Server::Baseの再起動ロジック。 - 主な影響:
- 開発モードでのコードリロード(
before_class_unload→ActionCable.server.restart)を繰り返しても、クローズ済み接続が内部に溜まらなくなる。 #connectionsやopen_connections_statisticsが、より正確に「現在オープンしている接続」を反映するようになる。
- 開発モードでのコードリロード(
- メモリ使用:
- 既存挙動では、dev 環境で長時間開発するとクローズ済み接続オブジェクトが Action Cable サーバに保持され続け、メモリ使用量が徐々に増加しうる。
- この PR により、
restart実行ごとにレジストリをクリアするため、そうしたリーク傾向が抑制される。
- 互換性:
restart後にconnectionsを参照するコードが、以前も論理的には「接続がクローズされているべき」タイミングであるため、connectionsが空になるのは自然な挙動の修正と考えられます。restartを「ソフト再起動」と捉えて手動で何らかの状態を引き継いでいたコードがある場合は、connectionsに依存していると挙動が変わる可能性がありますが、設計的にはrestart後はクリーンな状態が期待されるため、むしろ仕様に沿った形になります。
- スレッド・ロック:
connections_map.clearは既存の@mutex内で実行されるため、新たな競合やデッドロック要因は導入していません。handle_close/remove_connectionとの整合性も冪等性により担保されています。
- 参考情報 (あれば)
- 関連 PR: #57700
- 同様に Action Cable サーバの tear down ギャップ(ハートビートタイマーの後処理漏れ)を修正した PR。
- 修正ファイル:
actioncable/lib/action_cable/server/base.rbrestart内にconnections_map.clear追加。
actioncable/test/server/base_test.rbrestart後にconnectionsが空であることを確認するテスト追加。
- CHANGELOG:
- バグフィックスのため、CHANGELOG への追記は行われていません。
#57717 Coerce seeds and use_metadata_table booleans in UrlConfig
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
DATABASE_URLのクエリパラメータで指定されたseedsとuse_metadata_tableが、文字列"false"のまま扱われて常に truthy になっていた問題を修正し、既存のreplica/database_tasksと同様に真偽値へ強制変換するようにした PR です。これにより、?seeds=falseや?use_metadata_table=falseが正しく false として扱われます。
- 変更内容の詳細
どのような問題だったか
UrlConfig では、DATABASE_URL のクエリ文字列から読み込んだ設定を @configuration_hash に格納しています。このとき、クエリ文字列の値はすべて文字列になるため、そのままだと "false" も Ruby では truthy です。
過去の変更ですでに以下の2つは boolean 変換されていました:
to_boolean!(@configuration_hash, :replica)
to_boolean!(@configuration_hash, :database_tasks)しかし :seeds と :use_metadata_table は変換されておらず、例えば:
postgres://host/db?seeds=falseと書いても、
configuration_hash[:seeds] #=> "false"(Stringで truthy)となり、seeds? が truthy を返してしまうため、db:prepare のシーディングを無効化できませんでした。use_metadata_table についても同様で、メタデータテーブルの使用を DATABASE_URL 経由で無効化できない状態でした。
seeds? / use_metadata_table? の定義は以下のようになっており、保存されている値をそのまま返すため、変換漏れがそのまま挙動不良になっていました:
seeds?:configuration_hash.fetch(:seeds, primary?)use_metadata_table?:configuration_hash.fetch(:use_metadata_table, true)
今回の修正内容
UrlConfig の初期化処理に、seeds と use_metadata_table も boolean 変換対象として追加しました:
to_boolean!(@configuration_hash, :replica)
to_boolean!(@configuration_hash, :database_tasks)
to_boolean!(@configuration_hash, :seeds) # ← 追加
to_boolean!(@configuration_hash, :use_metadata_table) # ← 追加これにより、以下のような URL が:
postgres://host/db?seeds=false&use_metadata_table=false内部的には:
configuration_hash[:seeds] #=> false
configuration_hash[:use_metadata_table] #=> false
config.seeds? #=> false
config.use_metadata_table? #=> falseと解釈されるようになります。
テストの追加
activerecord/test/cases/database_configurations/url_config_test.rb にテストが追加されています。内容は既存の database_tasks に対するテストを踏襲しており、以下を検証しています:
?seeds=false→seeds?がfalseを返す?use_metadata_table=false→use_metadata_table?がfalseを返す
修正前は "false"(String)が入るためテストが失敗し、修正後に通ることを確認しています。
- 影響範囲・注意点
影響範囲
DATABASE_URL経由でseeds/use_metadata_tableを指定している環境に影響します。- これまで
"false"が truthy として扱われていたため、以下のように「うっかり動いていた」可能性があります:?seeds=falseを付けていたが、実際には seeds が実行されていた?use_metadata_table=falseを付けていたが、実際にはメタデータテーブルが使われていた
破壊的な変化の可能性
- もし現状、「
seeds/use_metadata_tableを本当に true にしたいが、DATABASE_URL上の都合で?seeds=falseのような値を付けていた」という特殊な誤用をしていると、今回の修正で false として扱われるようになり挙動が変わります。 - 通常は「明示的に
falseを指定したいのに効いていなかった」という状況が正しくなる方向なので、意図せぬ挙動変更は限定的なはずです。
- もし現状、「
マイグレーション・タスクへの影響
- Rails の DB 関連タスク(
db:prepareなど)でseeds?/use_metadata_table?の値を見て挙動を変える部分に影響します。 - CI や本番で
DATABASE_URLに?seeds=falseを指定して seed 実行を抑制したい、といったユースケースが正しく動くようになります。
- Rails の DB 関連タスク(
- 参考情報 (あれば)
- 本 PR が修正している過去コミットの意図:
- 63631e2d5b: 「
schema_dump,query_cache,replica,database_tasksをDATABASE_URLで設定可能にする」- コメント上は「すべての boolean 設定を扱う」とされていたが、
seeds/use_metadata_tableが変換漏れしていたものを今回補完。
- コメント上は「すべての boolean 設定を扱う」とされていたが、
- 63631e2d5b: 「
- 関連する boolean 設定(全て
DATABASE_URLのクエリ文字列から boolean として解釈されるべきもの):schema_dumpquery_cachereplicadatabase_tasksseeds(今回追加)use_metadata_table(今回追加)
#57719 Raise RecordNotFound for find(nil) on a composite primary key model
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
複合主キーを持つモデルでModel.find(nil)や引数なしのModel.findを呼び出した際に、想定外のNoMethodErrorが発生していた問題を修正し、単一主キーと同様にActiveRecord::RecordNotFoundを投げるようにしたPRです。複合主キー用の内部判定ロジックに nil ガードを追加し、それに対応するテストを追加しています。
- 変更内容の詳細
問題の背景
単一主キーのモデルでは、以下のような呼び出しは:
Post.find # => ActiveRecord::RecordNotFound: Couldn't find Post without an ID
Post.find(nil) # => ActiveRecord::RecordNotFound: Couldn't find Post without an IDのように ActiveRecord::RecordNotFound が投げられるのが仕様です。
しかし、複合主キーを持つモデル (例: Cpk::Book) では:
Cpk::Book.find
Cpk::Book.find(nil)を呼ぶと、ActiveRecord::RecordNotFound ではなく:
NoMethodError: undefined method `first` for nil:NilClassが発生していました。
原因は ActiveRecord::Key::Composite#expects_multiple_ids? の実装で、引数が nil の場合の考慮がなく、nil.first を呼び出してしまっていたことです。
既存コード(問題箇所)
# Key::Composite
def expects_multiple_ids?(value)
value.first.is_a?(Array) # nil.first => NoMethodError
endこれに対し、単一主キーのパスでは nil セーフになっていました:
# Key::Single
def expects_multiple_ids?(value)
value.is_a?(Array) # nil-safe
end修正内容
複合主キー側の expects_multiple_ids? にも同様の nil ガードを追加し、配列であることと、その先頭要素も配列である場合のみ「複数IDが渡されている」とみなすように変更しています。
修正後コード
def expects_multiple_ids?(value)
value.is_a?(Array) && value.first.is_a?(Array)
endこれにより:
value == nilの場合:value.is_a?(Array)がfalse→ 全体としてfalse- 以降の処理で正しく「単一ID扱い」になり、結果として
RecordNotFoundが発生するパスに流れる
value == [ [1, 2], [3, 4] ]のような「複数レコード用複合キー配列」の場合:value.is_a?(Array)→truevalue.first.is_a?(Array)→true- 複数IDとして処理される (期待どおりの挙動)
テスト追加
activerecord/test/cases/finder_test.rb に、すでに単一主キー用に存在しているテスト (test_find_with_ids_with_no_id_passed) と並べて、複合主キー用のテストを追加しています。
追加されたテストの意図は以下の2点:
assert_raises(ActiveRecord::RecordNotFound) { Cpk::Book.find }
assert_raises(ActiveRecord::RecordNotFound) { Cpk::Book.find(nil) }以前はこれらが NoMethodError になっていたのが、修正後は期待どおり RecordNotFound になることを検証しています。
- 影響範囲・注意点
影響範囲
- 複合主キーを使用している ActiveRecord モデル (
self.primary_keys = ...を設定しているモデルなど) のfind振る舞いに影響します。 - 対象は以下パターン:
Model.find(引数なし)Model.find(nil)
- これらの呼び出しで、以前は
NoMethodErrorが出ていたケースがActiveRecord::RecordNotFoundに変わります。
- 複合主キーを使用している ActiveRecord モデル (
互換性
- 正しい例外クラスに揃えるバグフィックスであり、ドキュメント上も
RecordNotFoundが想定されているため、仕様としては後方互換的な修正です。 - ただし、既存コードが誤って
NoMethodErrorを捕まえていた場合は、例外クラスの変更により挙動が変わる可能性があります。そのようなコードはActiveRecord::RecordNotFoundを捕まえるべきです。
- 正しい例外クラスに揃えるバグフィックスであり、ドキュメント上も
複合主キー用の複数ID指定
Model.find([[id1a, id1b], [id2a, id2b]])のような複数ID指定は、valueが配列であり、先頭要素も配列であるため、引き続き問題なく「複数ID」として扱われます。Model.find([id1a, id1b])のような「単一レコード用の複合キー配列」も、従来通り単一ID扱いです (expects_multiple_ids?はfalse)。
- 参考情報 (あれば)
修正ファイル:
activerecord/lib/active_record/key.rbKey::Composite#expects_multiple_ids?のロジック修正 (+1/-1)
activerecord/test/cases/finder_test.rb- 複合主キー向けの
findに関するテストを追加 (+5)
- 複合主キー向けの
関連仕様:
- Rails ガイド/ドキュメント上で
Model.find(nil)はActiveRecord::RecordNotFoundを投げることになっており、本PRは複合主キーでもこの仕様に揃えるためのバグ修正です。
- Rails ガイド/ドキュメント上で
#57722 Don't log a dangling "with arguments:" for jobs with no arguments
マージ日: 2026/6/14 | 作成者: @55728
- 概要 (1-2文で)
Active Job のログ出力で、引数なしジョブにもwith arguments:という文言だけがぶら下がって出力されてしまう問題を修正する PR です。構造化イベント対応時に削除されていた「引数が空かどうかのチェック」を復元し、従来通りのログフォーマットに戻しています。
- 変更内容の詳細
問題の背景
ActiveJob::LogSubscriber#args_info は、ジョブの引数をログに出すためのヘルパーメソッドです。
構造化イベント対応(event[:payload] ベースの実装)に書き換えられた際に、もともとあった「引数が空でないかのチェック (any?)」が抜け落ちました。
以前は概ね以下のようなロジックでした(イメージ):
# 以前(構造化イベントリライト前)のイメージ
def args_info(job)
if job.class.log_arguments? && job.arguments.any?
" with arguments: " + job.arguments.map { ... }.join(", ")
else
""
end
endこれが構造化イベント対応後は以下のようになっていました:
def args_info(event)
if (arguments = event[:payload][:arguments])
" with arguments: " + arguments.map { ... }.join(", ")
else
""
end
endここで:
- 引数なしジョブの場合でも
event[:payload][:arguments]には空配列[]が入る - Ruby では
[]は truthy なのでif (arguments = ...)が常に真になる - その結果、
"with arguments: "という接頭辞だけ付き、肝心の中身が空の状態でログに出てしまう
例:
Enqueued MyJob (Job ID: ...) with arguments:
Performing MyJob (Job ID: ...) with arguments:(「with arguments: 」の後ろが空)
今回の修正
この PR では、以前の挙動と同じく「引数が空配列でないこと」をチェックするように修正しています:
# activejob/lib/active_job/log_subscriber.rb
def args_info(event)
if (arguments = event[:payload][:arguments]) && arguments.any?
" with arguments: " + arguments.map { ... }.join(", ")
else
""
end
endこれにより:
- 引数ありのジョブ: これまで通り
with arguments: foo, barのように出力 - 引数なしのジョブ:
with arguments:自体を出力しない(完全に空文字を返す)
テストの追加
activejob/test/cases/logging_test.rb にテストが追加されています。
- 引数なしジョブ (
ConfigurationJobなど) を enqueue して実行 - ログ出力をキャプチャし、
with arguments:という文字列が含まれないことをアサート
テストの意図:
- Enqueued ログ行
- Performing ログ行
のどちらにも余計な with arguments: が付いていないことを保証しています。
- 影響範囲・注意点
- 影響範囲は Active Job のログ出力のみです。ジョブの実行ロジックやキューイング処理そのものには影響しません。
- 引数ありジョブのログフォーマットは変わりません。変わるのは「引数なしジョブの時に、
with arguments:という文言が出るかどうか」のみです。 - 構造化イベント導入後(このバグが入り込んでいた期間)に、ログパースや監視ツール側で「
with arguments:が常に出る前提」で実装していた場合は、今回の修正でその前提が崩れます。- ただし、元々の(構造化イベント前の)Rails の挙動に戻っただけなので、正しい仕様は「引数がある場合のみ
with arguments:を出す」と理解しておくのがよいです。
- ただし、元々の(構造化イベント前の)Rails の挙動に戻っただけなので、正しい仕様は「引数がある場合のみ
- パフォーマンス面の影響はほぼゼロです。
arguments.any?のチェック追加のみで、ログ出力時にしか呼ばれません。
- 参考情報 (あれば)
- 対象クラス:
ActiveJob::LogSubscriber- ジョブの
Enqueued,Performing,Performedなどのログフォーマットを司るクラス
- ジョブの
- バグの原因: 構造化イベント対応のリファクタリング中に
.any?チェックが削除されたことによる仕様逸脱 - テストファイル:
activejob/test/cases/logging_test.rb- ログメッセージの仕様がテストで明示されているので、カスタムロガーやログパーサを書く際の参考になります。
#57709 Restart the Redis listener thread when it dies
マージ日: 2026/6/14 | 作成者: @chaadow
- 概要 (1-2文で)
Action Cable の Redis サブスクリプションアダプタにおいて、リスナースレッドが死んだまま二度と復帰せず、そのプロセスでのブロードキャストが黙って失われる問題を修正する PR です。リスナースレッドが死亡していた場合に再起動できるようにし、その振る舞いをテストで保証しています。
- 変更内容の詳細
問題の背景
Action Cable の Redis アダプタでは、Redis の pub/sub 用に「リスナースレッド」が常駐し、購読中のチャンネルに届いたメッセージを受け取っています。
ensure_listener_running は
@thread ||= Thread.new { ... }のように「一度作ったスレッドをメモ化」して再利用する仕組みになっています。
ところが、以下のようなケースでスレッドが死ぬと問題が起こります。
- 再接続処理の試行回数 (
reconnect_attempts) を使い切ってしまった場合ensure_listener_runningの内部で Redis 再接続をリトライするが、retry_connecting?がfalseを返した時点で rescue ブロックを抜けてスレッド終了
- スレッドオブジェクト自体は
@threadに残るが、既に dead になっている (non-nil かつ@thread.alive? == false) - その後の
subscribe呼び出しは、@thread ||= ...の||=によって「既に @thread があるから新しいスレッドは作らない」という挙動になり、新しいリスナーが起動しない - 新しい購読要求は
@when_connectedキューに積まれるが、@subscribed_clientはnilのままなので一向に処理されず、そのプロセスでは以後のブロードキャストが全部「無視される」状態になる
これにより、プロセス再起動まで該当プロセス配下の WebSocket 接続に対するブロードキャストが失われてしまう、という致命的な不具合が生じていました。
修正内容 (リスナースレッドの再起動)
actioncable/lib/action_cable/subscription_adapter/redis.rb において、ensure_listener_running の実装が変更されています。
ポイントは以下です。
@threadが存在していても「死んでいるスレッド」なら一度@threadをnilにリセットする- その上で
@thread ||= Thread.newによって新しいリスナースレッドを起動する - 併せて、再接続回数カウンタも「スレッド再生成時」にリセットされるようにする (=新しいリスナーとしてまっさらな状態で復帰)
疑似コードイメージ:
def ensure_listener_running
# 1. すでにあるスレッドが死んでいたら破棄
if defined?(@thread) && @thread && !@thread.alive?
@thread = nil
@reconnect_attempts = 0 # 実際にはそれに準じたカウンタのリセット
end
# 2. 生きているスレッドがなければ新しく起動
@thread ||= Thread.new do
begin
# Redis 接続&購読ループ
rescue => e
# retry_connecting? が false でなければ再接続を試みる
# ここで試行し尽くしてスレッドが死ぬとき、
# 次回 ensure_listener_running が呼ばれた際に上記の判定で再起動される
end
end
endこれにより、
- 再接続試行を使い切って一度はスレッドが死んでも
- 次の
subscribe呼び出し時にensure_listener_runningが「死んだスレッド」を検知して破棄 - 新しいリスナースレッドを起動し直す
という復旧パスが追加されます。
テストの追加
actioncable/test/subscription_adapter/redis_test.rb に回帰テストが追加されています。
内容の要点:
reconnect_attempts: 0の設定で Redis アダプタを初期化- これにより「再接続を1度も行わず即座に諦める」挙動を強制
- pub/sub 接続を意図的に切ることでリスナースレッドを殺す
- 新しいチャンネルに
subscribeし、その後のブロードキャストが再び届くことを確認- = 死んだリスナースレッドが再起動されたことの検証
また、このテストで「スレッドの死をポーリングで待つ」ために、ActionCable::TestCase に小さな wait_for ヘルパーが追加されています (actioncable/test/test_helper.rb)。
- Active Record のテストスイートにある
wait_forと同様のユーティリティ - 一定時間、条件が満たされるまでポーリングするために利用
- 固定
sleepに頼らず、安定かつ高速なテストにする意図
- 影響範囲・注意点
- 対象: Action Cable で Redis アダプタ (
ActionCable::SubscriptionAdapter::Redis) を使用している環境 - 直接の影響:
- これまで「Redis との pub/sub 接続が落ちて再接続試行を使い切った後、リスナースレッドが死にっぱなしになる」ケースで、ブロードキャストが黙って失われていた
- この PR により、その後の新規 subscribe のタイミングでリスナースレッドが自動的に再起動し、ブロードキャストが復旧するようになる
- Sentinel サブスクリプションとの関係 (#57690):
- #57690 では「unsubscribe による購読数 0 → スレッド終了」という典型的な死因を、内部の sentinel 購読によって防いだ
- 本 PR は「それ以外の死因 (例: 再接続試行の枯渇)」に対して、死んだあとに再起動する経路を追加する
- 両方を合わせることで、通常運用時のスレッド死亡も、障害時の死亡もカバーし、より堅牢になる
運用上の注意:
reconnect_attemptsを 0 やかなり小さい値にしていると、障害発生時に「すぐにリスナーが死んでは subscribe のたびに再起動される」ような挙動も起こり得ます- ただし、その場合でも「黙って配信ロストし続ける」よりは安全側の挙動です
- 本 PR 自体は設定値を変えていないので、必要なら各アプリ側で
reconnect_attempts・reconnect_delayなどのチューニングを検討するとよいです
- 参考情報 (あれば)
- 関連 PR: #57690 (Redis リスナースレッドが unsubscribe で死ぬ問題に対する sentinel サブスクリプションの導入)
- 実装の考え方:
- 長寿命スレッドを
@thread ||= ...でメモ化する場合、「スレッドが正常終了 or 例外死したときにそれを検知して再生成するパスを用意する」ことが重要 - 今回のように
@thread&.alive?をチェックし、dead なら@thread = nilしてから再生成するパターンは、スレッドプール/バックグラウンドワーカーなどでも使える定石です
- 長寿命スレッドを
#57697 Don't fire remove_channel for an unknown channel
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
Action Cable のSubscriberMap#remove_subscriberが、存在しないチャンネルに対してもremove_channel(UNLISTEN / unsubscribe)を発火してしまうバグを、ハッシュのキー存在チェックで防ぐ修正です。これにより、まだ購読登録されていない・すでに削除済みのチャンネルに対して、不要な UNLISTEN / unsubscribe が飛ばなくなります。
- 変更内容の詳細(サンプルコード含む)
問題の背景
SubscriberMap の内部では、購読者リストを次のようなハッシュで管理しています:
@subscribers = Hash.new { |h, k| h[k] = [] }この「デフォルト proc 付きハッシュ」の特徴は、@subscribers[channel] とアクセスした時に、そのキーが存在しないと自動的に channel => [] が追加される(オートバイビフィケーション)ことです。
broadcastとadd_subscriberは、事前に@subscribers.key?(channel)を見て、未知のチャンネルでは何もしないようになっていました。- 一方で、
remove_subscriberだけが 無条件で@subscribers[channel]にアクセス しており、存在しないチャンネルに対しても空配列を自動生成してしまっていました。
その結果:
- 存在しないチャンネルに対して
remove_subscriberが呼ばれる @subscribers[channel]が空配列として勝手に作られる- 当然空なので
@subscribers[channel].empty?がtrue remove_channel channelが呼ばれ、実際に UNLISTEN / Redis unsubscribe が発行される
つまり「サーバー自身は購読していないチャンネル」に対しても、UNLISTEN / unsubscribe を投げてしまう不整合が発生していました。
特に SubscriberMap::Async 経由の非同期処理では、
add_subscriberとremove_subscriberを 別タスク として executor に投げている- そのため「subscribe → すぐ unsubscribe」のようなケースで、「remove の方が先に走る」ことがある
このレース条件により、「まだチャンネル登録が完了していないのに remove_subscriber が走り、未知のチャンネルとしてオートバイビファイ → 不要な UNLISTEN が発行」という状況が現実に起こり得ます。
修正内容
remove_subscriber にも broadcast と同様のキー存在チェックを追加し、「知らないチャンネルなら何もしない」ように合わせました。
修正差分:
def remove_subscriber(channel, subscriber)
@sync.synchronize do
+ return if !@subscribers.key?(channel)
+
@subscribers[channel].delete(subscriber)
if @subscribers[channel].empty?
@subscribers.delete channel
remove_channel channel
end
end
endポイント:
@sync.synchronizeブロックの中で@subscribers.key?(channel)をチェック- キーがなければその場で
return(呼び出し元は戻り値を使っていないため互換性問題なし) - これにより
@subscribers[channel]アクセスによるオートバイビフィケーションを避ける
テストの追加
SubscriberMapTest に新しいテストが追加されています:
- 「unknown な channel に対して
remove_subscriberを呼んでもremove_channelが呼ばれない」ことを検証 - 既存の「broadcast should not change subscribers」に並ぶ形でテストが追加
テストの結果:
- 新規テスト: 修正前は
remove_channelが 1 回呼ばれるため RED、修正後は 0 回で GREEN actioncable/test/subscription_adapter/subscriber_map_test.rb全体: 2 run / 0 failure
- 影響範囲・注意点
- 対象箇所:
actioncable/lib/action_cable/subscription_adapter/subscriber_map.rbactioncable/test/subscription_adapter/subscriber_map_test.rb
- 主な影響:
- Action Cable の Redis / PostgreSQL / Inline いずれの pubsub アダプタでも、未知のチャンネルに対する
remove_subscriber呼び出しは完全に無視されるようになります。 - その代わりに「不要な UNLISTEN / unsubscribe」が送られなくなるため、バックエンド側のログやメトリクスのノイズが減り、状態整合性も改善されます。
- Action Cable の Redis / PostgreSQL / Inline いずれの pubsub アダプタでも、未知のチャンネルに対する
- 後方互換性:
- 呼び出し元は
remove_subscriberの戻り値を利用しておらず、副作用(チャンネルを本当に unsubscribe するかどうか)のみを期待している実装なので、仕様上は「より正しい挙動」への修正です。 - 「存在しないチャンネルに対して unsubscribe を投げる」ことに依存しているアプリケーションがある可能性は極めて低く、実質的に互換性問題はないと考えられます。
- 呼び出し元は
- レース条件への影響:
- Async アダプタでの「subscribe → 直後に unsubscribe」ケースなど、タスクの順序が逆転しても、「まだ登録されていないチャンネルへの remove では何も起きない」という安全な挙動になります。
- 逆に言えば、「remove の方が先に走った場合、そのチャンネルは一度も UNLISTEN されない」ということになりますが、サーバー側ではそもそも購読が開始されていないため、状態としては一貫しています。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57697
- 修正対象クラス:
ActionCable::SubscriptionAdapter::SubscriberMap - 関連する既存の挙動:
broadcastは既にreturn if !@subscribers.key?(channel)を行っていたadd_subscriberもkey?を用いて「新規チャンネルかどうか」を判定しており、今回の修正で 3 メソッドのポリシーが統一された形になります。
#57677 Fix Array#to_query to skip empty Array elements, mirroring the empty-Hash case
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
Array#to_queryが、ネストされた空配列の要素をクエリ文字列から除外するように修正されました。これにより、既に実装されていた「空ハッシュ要素は落とす」という挙動と整合がとれ、余計な「値なしパラメータ」が出力されなくなります。
- 変更内容の詳細
問題となっていた挙動
Array#to_query は以前の修正により、「ネストされた要素をシリアライズした結果が空文字列なら、その要素はクエリから除外する」というフィルタリングを行うようになっていました。
- 空ハッシュ
{}の場合は{}.to_query(prefix)が空文字列になり、結果的にその要素はクエリから削除される挙動になっていた。
一方で空配列 [] については内部実装上、次のような挙動になっていました。
[].to_query(prefix)はnil.to_query(prefix)にフォールスルーするnil.to_query(prefix)は「キーだけ」をエスケープした文字列 (例:a%5B%5D%5B%5D) を返す- その結果、値がないパラメータ断片(
a[][]に相当)がクエリ文字列に紛れ込む
具体例:
require "active_support/core_ext/object/to_query"
[1, [], 2].to_query("a")
# 修正前: "a%5B%5D=1&a%5B%5D%5B%5D&a%5B%5D=2"
# ↑ これが余計なセグメント
{ a: [1, {}, 2] }.to_query
# => "a%5B%5D=1&a%5B%5D=2" # 空ハッシュ要素は既に除外されていた
{ a: [1, [], 2] }.to_query
# 修正前は "a%5B%5D=1&a%5B%5D%5B%5D&a%5B%5D=2"
# 本来は空ハッシュ同様、空配列要素も除外したい修正内容
Array#to_query 内で、要素ごとに to_query へ委譲する前に、以下のチェックを追加しました。
- 要素が「空ハッシュ」または「空配列」であれば、その要素はスキップする(クエリに出さない)
ポイント:
- これは既に
Hash#to_queryでは行われていたロジックであり、Array#to_queryでも同じガードを入れて挙動を揃えた形です。 - トップレベルが空配列のケースは従来どおりで、変更していません。
例:
[].to_query("a")
# 修正前後とも: "a%5B%5D"
# (トップレベル空配列は「キーだけの配列パラメータ」として扱われる仕様のまま)
{ a: [1, [], 2] }.to_query
# 修正前: "a%5B%5D=1&a%5B%5D%5B%5D&a%5B%5D=2"
# 修正後: "a%5B%5D=1&a%5B%5D=2"テストの追加
activesupport/test/core_ext/object/to_query_test.rb に以下のテストが追加されました。
test_array_with_empty_arraytest_array_with_empty_hashと対になるテスト{ a: [1, [], 2] }.to_queryが"a%5B%5D=1&a%5B%5D=2"になることを検証
このテストは修正前の実装では失敗し、修正後にパスすることが確認されています。
- 影響範囲・注意点
影響範囲
ActiveSupportのObject#to_query/Array#to_queryを使っているコードのうち、
「配列の中に空配列を含むパラメータ」をクエリにシリアライズしている箇所が影響します。- 典型的には、フォームヘルパーやURLヘルパーを通して生成されるパラメータで、
ネストした配列を扱っているケース(例:params[:a] = [1, [], 2])です。
互換性の観点
- これまでは「意図しない余分なキーだけのパラメータ」が出ていた状態であり、
仕様というよりは不整合/バグに近い挙動です。 - ただし、もし既存コードで「この余分なキー (例:
a[][]) をサーバ側で何らかのトリガーとして利用していた」ような特殊な処理があれば、そのキーが今後送られてこなくなります。 - 一般的なRailsアプリでは、むしろ意図通りのクリーンなクエリ文字列になる改善と考えてよいです。
- これまでは「意図しない余分なキーだけのパラメータ」が出ていた状態であり、
注意点
- トップレベル空配列 (
[].to_query("a") #=> "a%5B%5D") の挙動は変わっていません。
「配列の中にある空配列」をスキップするだけで、「配列そのものが空」のケースは従来のままです。 - 「空の配列要素を明示的に送りたい」ようなユースケースがある場合は、別の表現(
nilや特別な値)で表す必要があります。
- トップレベル空配列 (
- 参考情報 (あれば)
- 該当PR: https://github.com/rails/rails/pull/57677
- 関連コミット: 68160c4417 (
Array#to_queryのフィルタリングロジックが導入されたコミット) - 関連メソッド:
Object#to_queryHash#to_queryArray#to_query(ActiveSupport コア拡張)
#57655 Support a single composite primary key id in update/update!
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
Rails のActiveRecord::Base.update/update!が、複合主キー(composite primary key)モデルに対して「単一レコードの id」として配列を渡した場合にRecordNotFoundを出してしまう問題を修正した PR です。複合主キーでも単一主キーと同様にModel.update(record.id, attrs)が正しく単一更新として動作するようになります。
- 変更内容の詳細(あればサンプルコードも含めて)
これまでの挙動と問題点
単一主キーのモデルでは、クラスメソッド update / update! は以下の2パターンを取りうる挙動をします:
# 単一レコード更新
Topic.update(1, title: "updated")
# 複数レコード更新
Topic.update(
[1, 2],
[{ title: "updated 1" }, { title: "updated 2" }]
)ここで、「複数 id かどうか」の判定に id.is_a?(Array) が使われていました。
複合主キーのモデルでは、1レコードの id 自体が配列になります(例: [author_id, id])。そのため:
book = Cpk::Book.first
# 期待: book 1件だけを更新
Cpk::Book.update(book.id, title: "updated")という呼び出しにおいて、book.id が配列であるため 「複数 id」と誤判定 されてしまい、内部的に要素ごと(author_id と id 各々)で find を呼び出そうとして ActiveRecord::RecordNotFound になる、というバグが発生していました。
修正内容
この PR では、「単一 id か複数 id か」の判定ロジック を以下のように変更しています。
- まず
:all(id 省略)パターンを優先的に判定Model.update(:all, attrs)の既存挙動を壊さないため。 - それ以外の場合に、「複数 id かどうか」を以下のルールで判断する private メソッド
update_multiple_ids?を新設:- 単一主キー:
idが配列なら「複数 id」(例:[1, 2])
- 複合主キー:
- 1レコードの id は配列(例:
[author_id, id]) - 複数レコードなら「配列の配列」(例:
[[author1, id1], [author2, id2]]) - したがって、「複合主キーで複数 id」の場合は
idが「配列の配列」であることをチェックする
- 1レコードの id は配列(例:
- 単一主キー:
Relation#destroy が既に同様のロジック(配列の配列かどうか)で判定しており、その考え方を update / update! に合わせた形です。
この helper メソッドは update と update! の両方から使われ、単一主キーのコードパスは(挙動として)変わらないように実装されています。
修正後のサンプルコード
# 単一主キー: これまで通り
Topic.update(1, title: "updated") # 単一更新
Topic.update([1, 2], [{ title: "a" }, { title: "b" }]) # 複数更新
# 複合主キー: この PR により、以下が正しく動作する
book = Cpk::Book.first
# book.id は [author_id, id] のような配列
# 1. 単一レコード更新 (以前は RecordNotFound になっていた)
Cpk::Book.update(book.id, title: "updated")
# => 1件の book が更新される
# 2. 複数レコード更新 (配列の配列を渡す)
books = Cpk::Book.limit(2)
Cpk::Book.update(
books.map(&:id), # [[author1,id1], [author2,id2]]
[{ title: "a" }, { title: "b" }]
)
# => 2件の book がそれぞれ更新されるテストとしては activerecord/test/cases/persistence_test.rb に、複合主キーの単一 id 更新が成功することなどを確認するケースが追加されています。
- 影響範囲・注意点
- 複合主キーを使っているアプリ:
- これまで
Model.update(record.id, attrs)がRecordNotFoundになっていたケースが、正しく単一更新として動作するようになります。 - もしバグ回避のために独自実装(
find+updateなど)を入れていた場合は、PR 適用後にそれを簡略化できる可能性があります。
- これまで
- 単一主キーのモデル:
- 仕様としての挙動は変わらず、
update(1, attrs)/update([1, 2], [attrs1, attrs2])は従来通りに動作する想定です。
- 仕様としての挙動は変わらず、
- 複数 id の引数形式:
- 複合主キーで複数レコードを
updateする場合は、「配列の配列」形式で id を渡す必要がある点に注意してください。- OK:
[[author1, id1], [author2, id2]] - NG(意図した複数更新にはならない / 単一扱いになる):
[author1, id1](これは単一レコードの id)
- OK:
- 複合主キーで複数レコードを
:allの扱い:Model.update(:all, attrs)の挙動は変わりません。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57655
- 類似の判定ロジック:
Relation#destroyが複合主キー対応のために行っている「配列の配列」判定と同等の考え方を、update/update!にも適用した修正です。
#57690 Keep the Redis listener alive when the last channel is unsubscribed
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
Redis クライアントの実装変更により、最後のチャネルがUNSUBSCRIBEされた瞬間に Action Cable の Redis リスナー用スレッドが終了してしまう不具合があり、それを修正する PRです。内部用の「番兵」チャネル(_action_cable_internal)への購読を復活させることで、通常の購読解除ではリスナーが死なず、明示的なシャットダウン時のみ終了するようにしました。
- 変更内容の詳細
問題の背景
以前の
redisgem ベースの実装では、Action Cable は常に_action_cable_internalという内部チャネルに購読していました。このため Redis の「購読数」は、アプリ側の全てのチャンネルを
UNSUBSCRIBEしても 1 は維持され、Redis の subscribe ループがゼロ購読で終了することは「明示的な shutdown 時」以外には起こりませんでした。新しい
redis-clientベースの実装 (ef812c2652) では、この内部チャネルへの購読がなくなり、- 最後のユーザーチャネルが
UNSUBSCRIBEされると購読数が 0 になり - listen ループが
breakし、@subscribed_clientがnilにされる
という挙動になっていました。
- 最後のユーザーチャネルが
ensure_listener_runningは以下のようなガードになっており:ruby@thread ||= Thread.new { listen }一度死んだスレッドは再生成されません。その結果:
- 以降の
subscribe呼び出しは全て@when_connectedキューに積まれるだけで実行されない @subscribed_clientはずっとnil- Redis からのメッセージも届かないので 全ブロードキャストが黙って捨てられる
- クライアントは WebSocket 接続自体は張れるが、Redis と繋がらないためメッセージが飛ばない
という「サイレントな本番障害」が発生し得る状態でした。
特に以下のような環境で起きやすいです:- 低トラフィック環境
- チャンネルが1つしか無いアプリ
- デプロイ中にノードを drain しているとき など
- 以降の
修正内容
1) 番兵チャネル _action_cable_internal への購読を復活
actioncable/lib/action_cable/subscription_adapter/redis.rb にて、listen メソッド中で @subscribed_client をセットした直後に、内部チャネルへの購読を行うように変更されています。
おおよそのイメージは以下です(※説明用の擬似コード):
def listen
@subscribed_client = build_subscribed_client
# ここで番兵を追加
@subscribed_client.call("SUBSCRIBE", "_action_cable_internal")
@subscribed_client.listen do |message|
# ...
end
ensure
@subscribed_client = nil
end重要なポイント:
_action_cable_internalはSubscriberMap(実際の Action Cable チャンネルと Redis チャンネルを紐づけるマップ)にエントリを持たないため、- ここに publish されても受信処理は「何もしない」=完全な no-op
- しかし Redis 的には「常に購読数が 1 以上」の状態が保たれるので、
- 通常の
UNSUBSCRIBE(ユーザーチャネルが0になる)では listen ループは終了しない - 全てのチャネル(この内部チャネルを含む)が明示的に
UNSUBSCRIBEされた場合のみ購読数が 0 になり、ループ終了 → スレッド終了となる
- 通常の
- これにより、以前の redis gem ベースの挙動と同じ「番兵つきの常駐リスナー」が復活します。
2) 回帰テストの追加
actioncable/test/subscription_adapter/redis_test.rb にテストが追加されています(+17 行)。
テストの内容(概念的な流れ):
- 同じ Redis アダプタインスタンスに対して、順番に2回
subscribeを行うテストケースを用意 - 1回目の
subscribeでチャンネルを購読 →unsubscribeまで行い、この時点で Redis 側の購読数が 0 になる状況を再現 - その後、2回目の
subscribeを行い、- リスナーのスレッドがまだ生きていて
- 実際に publish → subscribe が正常動作すること を検証
これにより、「一度全チャンネルが unsubscribe されても、以降の subscribe が正常に働く(リスナーが死んでいない)」ことを回帰テストで保証しています。
- 影響範囲・注意点
- 影響範囲
- Action Cable の Redis サブスクリプションアダプタ(
actioncable/lib/action_cable/subscription_adapter/redis.rb)を利用しているアプリ全般。 - 特に、
- チャンネル数が少ない
- 接続が一時的に全て切れることがある
といった環境で、Redis リスナーが知らないうちに停止してしまう問題を防ぎます。
- Action Cable の Redis サブスクリプションアダプタ(
- 互換性
_action_cable_internalチャンネルは以前の実装でも使われており、再導入なので互換性上の問題はほぼありません。- このチャネルに対してアプリが明示的に subscribe/publish している前提はない(内部用)ため、通常のアプリコードが影響を受けることはありません。
- パフォーマンス/リソース
- 常に1つのチャネルに購読し続けることになるため、「購読数ゼロで完全に Redis から切り離される」ことはなくなります。
- ただし listen 用の接続はもともと張りっぱなしにする設計であり、以前の redis gem 実装でも同様だったため、特段の追加コストはありません。
- 運用上の注意
- もし「意図的に listener を止めたい」場合(例: シャットダウンフックなど)には、
- 内部チャネルも含めて
UNSUBSCRIBEするか、 - またはアダプタの明示的な shutdown API を使う
などの手段が必要ですが、これは以前と同じ考え方です。
- 内部チャネルも含めて
- 逆に、今回の修正により「一時的に全クライアントがいなくなっただけ」でリスナーが落ちるケースは解消されます。
- もし「意図的に listener を止めたい」場合(例: シャットダウンフックなど)には、
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/57690
- 問題の導入元と思われるコミット:
ef812c2652(redis-client ベースへの書き換え) - 変更概要:
- 変更ファイル数: 2
- 追加行数: 26
- 削除行数: 0
この修正は、Redis ベースの Action Cable を使っている本番環境における「ごく一部のタイミングでのみ発生する静かな障害」を潰す目的のものなので、redis-client に移行している Rails バージョンを利用中であればアップデート推奨です。
#57700 Shut down the heartbeat timer on server restart
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
Action Cable サーバ再起動時に、ハートビート用の@heartbeat_timerが停止されずに残り続けていた不具合を修正する PR です。
開発環境のコードリロードごとにスレッドプールとタイマーがリーク・再生成される問題を解消します。
- 変更内容の詳細
問題の内容
ActionCable::Server::Base#restart は、サーバ再起動時に以下のリソースを破棄していました:
@worker_pool@executor@pubsub
しかし、定期的にハートビートを送るための @heartbeat_timer (Concurrent::TimerTask) だけはそのまま動き続けていました。
このタイマーのブロック内では executor が呼ばれますが、executor は「遅延生成」されるため、次のような状況が発生していました:
restartが呼ばれる
→ 既存の executor スレッドプールは停止・破棄される。- しかし、古い
@heartbeat_timerは生きていて、BEAT_INTERVALごとに実行される。 - タイマー内の処理で
executorを呼ぶ
→ 「必要に応じて再生成」の仕組みにより、新しい executor スレッドプールが勝手に作られてしまう。 - 結果として:
- teardown したはずの executor が即座に復活する
- 古い
@heartbeat_timerも生き続ける - コードリロードのたびにスレッドプール・タイマーが増え、リソースリーク/スレッド churn を起こす
開発環境では restart が before_class_unload を通じてクラスリロードごとに呼ばれるため、この問題は特に dev で顕著になります。
修正内容
ActionCable::Server::Base#restart 内で、他のリソースと同様に @heartbeat_timer も停止・クリアするようにしました。
該当部分のイメージ:
@mutex.synchronize do
# Shutdown the heartbeat timer
@heartbeat_timer.shutdown if @heartbeat_timer
@heartbeat_timer = nil
# Shutdown the worker pool
@worker_pool.halt if @worker_pool
@worker_pool = nil
# ... 既存の teardown ロジック ...
endあわせて、initialize でも他の遅延生成リソースと同様に @heartbeat_timer を nil で初期化するリストに追加し、一貫性を取っています。こうすることで:
setup_heartbeat_timerが次のリクエスト時に呼ばれたときに、- 必要に応じて新しい
@heartbeat_timerを遅延生成する、
という既存パターンにきれいに乗るようになっています。
テスト
ActionCable::Server::Base のテストに以下を追加:
"restart shuts down the heartbeat timer"
テスト内容の要点:
restart実行後に:@heartbeat_timerがrunning?でないこと@heartbeat_timerがnilになっていること
既存の "restart shuts down ..." 系テストと並ぶ形で追加されており、4回実行して red/green が確認されています。
CHANGELOG には記載なし(バグ修正扱い)。
- 影響範囲・注意点
- 対象:
ActionCable::Server::Baseの再起動処理 (#restart) - 主な影響:
- 開発環境でのコードリロード時に、不要なスレッドプールやタイマーが増殖しなくなる。
- 長時間開発サーバを動かしていても、Action Cable 周りのスレッド・タイマーがリークしにくくなる。
- 本番環境への影響:
- 通常、本番で
restartが頻繁に呼ばれるケースは少ないため、主なメリットは安定性・リソース解放の明確化。 - Restart 後は、次のリクエストが来たタイミングでハートビートタイマーが再生成されるため、ハートビート動作は従来どおり維持される。
- 通常、本番で
互換性を壊す変更ではなく、「本来そうあるべきだった teardown を正しく行う」性質の修正です。
- 参考情報 (あれば)
- 対象ファイル:
actioncable/lib/action_cable/server/base.rb(+5/-1)actioncable/test/server/base_test.rb(+11/-0)
- 関連キーワード:
Concurrent::TimerTaskBEAT_INTERVAL- 遅延生成された executor / worker_pool / pubsub
before_class_unloadによる開発時の server restart
#57699 Raise ChannelNotFound for a subscribe command without a channel
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
Action Cable のsubscribeコマンドでchannelキーが省略された場合に、これまで素のNoMethodErrorが発生していたのを、他の経路と同様にChannelNotFound例外が発生するように統一したバグフィックスです。サーバ側は異常クライアントからの不正な subscribe メッセージに対して、より一貫したエラー処理ができるようになります。
- 変更内容の詳細
問題の背景
Action Cable ではクライアントからの subscribe コマンドは、概ね以下のような JSON を想定しています。
{
"command": "subscribe",
"identifier": "{\"channel\":\"ChatChannel\",\"room_id\":1}"
}このうち identifier は JSON 文字列で、サーバ側ではパースして id_options(Hash)として扱います。その際、id_options[:channel] を元にチャンネルクラスを定数解決します。
問題になっていたのは、クライアントが誤って channel キーを含まない identifier を送ったケースです。
例:
{
"command": "subscribe",
"identifier": "{\"id\":1}"
}この場合、Rails 側では以下のような処理が行われていました。
# 変更前のコード(簡略)
subscription_klass = id_options[:channel].safe_constantize
if subscription_klass && subscription_klass < Base
# ...
else
raise ChannelNotFound
endid_options[:channel] が nil になるため、nil.safe_constantize が呼ばれ、NoMethodError(undefined method safe_constantize' for nil:NilClass)が発生していました。この例外は Action Cable が想定している「チャンネルが解決できない」場合の ChannelNotFound` とは異なり、スタックトレース的にも分かりづらく、ハンドリングもしにくい状況でした。
修正内容
nil の場合を考慮して safe navigation 演算子 &. を使用し、channel が存在しないケースでも通常の「チャンネル解決失敗」フローに乗るようにしています。
- subscription_klass = id_options[:channel].safe_constantize
+ subscription_klass = id_options[:channel]&.safe_constantizeこれにより、
id_options[:channel]が存在し、有効なクラス名 → そのクラスをsubscription_klassに設定id_options[:channel]が存在するが、クラス定数として解決できない →safe_constantizeがnilを返し、elseブランチに入りChannelNotFoundを raiseid_options[:channel]自体が存在しない(nil)→&.によりsafe_constantizeが呼ばれず、subscription_klassはnilとなり、同じくelseブランチでChannelNotFoundを raise
という挙動になります。
テスト追加
actioncable/test/connection/subscriptions_test.rb に以下のようなテストが追加されています(概要):
- テスト名: "subscribe command without a channel"
- 内容:
channelキーを含まない subscribe コマンドを送る- 以前は
undefined method 'safe_constantize' for nilで落ちていたところ、 - 修正後は
ChannelNotFound例外が発生することを確認
既存の「Base を継承していないチャンネル(Base channel)」のテストの横に追加されており、一連の「チャンネル解決に失敗した場合の動作」のカバレッジを補強しています。
- 影響範囲・注意点
- 主な影響範囲は Action Cable の subscribe 処理です。
- 想定外の identifier(
channelキーの欠如)に対して、これまでNoMethodErrorが飛んでいた環境では、今後はChannelNotFoundが飛ぶようになります。
- 想定外の identifier(
- これにより:
- ログやエラーハンドリングの観点で、subscribe 失敗時の例外種別が一貫します。
ChannelNotFoundを前提にした救済処理(rescue, instrumentation 等)が正しく働くようになります。
- 互換性:
- 正常なクライアント(正しい
channelを送る)は挙動に変化はありません。 - 不正なクライアント/バグのあるクライアントに対して、これまで「500 エラー+NoMethodError」となっていたものが、「想定された例外型(
ChannelNotFound)」に変わる可能性があります。 - もしアプリケーション側で
NoMethodErrorを元に特別な処理をしていた場合(あまりない想定ですが)、動作が変わる可能性はあります。
- 正常なクライアント(正しい
- 参考情報 (あれば)
- 対象コード:
actioncable/lib/action_cable/connection/subscriptions.rb - 例外クラス:
ActionCable::Connection::Subscriptions::ChannelNotFound(Action Cable 内部で「存在しない/解決できないチャンネル」を表現するために使われる例外) - この PR はバグ修正であり、CHANGELOG には追加されていません。
ただし、Action Cable を直接利用しているアプリケーションや、カスタムのエラーハンドリングを行っているミドルウェアでは、例外種別の変化を念のため確認するとよいです。
#57695 Keep pending subscribe confirmations across a Redis reconnect
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
Redis の pub/sub 接続が切れた直後に発生していた「サブスクライブ確認(on_success)が二度と返ってこない」問題を修正し、再接続後も未完了の subscribe 確認を正しく引き継ぐようにした PR です。Redis クライアント刷新時のリグレッションで、Action Cable の Redis アダプタの挙動のみをピンポイントに修正しています。
- 変更内容の詳細
問題の内容
- 対象クラス:
ActionCable::SubscriptionAdapter::Redis::Listener - 現象:
- Redis の pub/sub 接続が切断されると
Listener#reset→ 再接続 →resubscribeという流れで復旧が行われる。 resetは内部状態のリセットとして以下を行っていた:@subscribed_client = nil@subscribe_callbacks.clear@when_connected.clear
- ところが
@subscribe_callbacksをclearしてしまうため、- ちょうどそのタイミングで
SUBSCRIBEを送信済みだが ack(["subscribe", "channel", 1])が戻ってきていない「in-flight な subscribe」がある場合、 - 再接続後に
resubscribeによって再度SUBSCRIBEは出されるものの、それに紐づくon_successコールバックが消えている。 - そのため ack が返ってきても何も実行されず、クライアント側では
confirm_subscription/subscribedが決して呼ばれない「永遠に pending のチャネル」になってしまう。
- ちょうどそのタイミングで
- Redis の pub/sub 接続が切断されると
- これは
redis-clientへの書き換え (ef812c2652) によって入り込んだリグレッションであり、別 PR #57690 で修正されている「最後のチャネルが unsubscribe されたときに Listener を生かし続ける問題」とは独立した不具合です。
修正内容
reset から @subscribe_callbacks.clear を削除し、pending な subscribe 確認情報を再接続後も維持するようにしました。
def reset
@subscription_lock.synchronize do
@subscribed_client = nil
- @subscribe_callbacks.clear
@when_connected.clear
end
endなぜこれで問題が解決するか
@subscribe_callbacksは「チャンネルごとの subscribe 確認用コールバック配列」を持つハッシュと考えられます。- subscribe が成功して ack を受け取ると、そのチャンネルに紐づくコールバックが実行され、その中で自分自身のエントリを削除するような実装になっている(PR 説明より)。
- つまり:
- すでに subscribe 済み(ack 済み)のチャネルは、コールバック実行時に自分のキーを削除しているため、
reset時点で@subscribe_callbacksに残っているのは「まだ ack が返ってきていない subscribe だけ」。 resetでこれを消さずに残しておけば、再接続後のresubscribeによって再度発行されるSUBSCRIBEと、元のコールバックを正しく結びつけられる。- コールバック配列が空なチャネルは ack 時に何も実行されないだけで、古い状態が残るわけではない。
- すでに subscribe 済み(ack 済み)のチャネルは、コールバック実行時に自分のキーを削除しているため、
- そのため、
clearをやめても「古い/不要なコールバックが大量に残る」といった問題は基本的に起きません。
テストの追加
実バグは「subscribe が in-flight の瞬間に接続が落ちる」というレース条件でしか起きないため、実 Redis を使うと再現が非常に難しい(ack がサブミリ秒で返ってくる)。
この PR では、実際の Listener#listen → reset → resubscribe → retry の経路を通しつつ、pub/sub 接続部分だけを疑似実装に差し替えるテストを追加しています。
ポイント:
- 疑似 pub/sub 接続の
next_eventは Ruby のQueueを使ってブロックする実装。- テスト側は Queue にイベントを順番に push することで、Listener のイベント処理順を完全に制御できる。
- シナリオ:
:dropを push →next_eventがRedisClient::ConnectionErrorを投げる →resetが呼ばれ、再接続 &resubscribeが走る。- その後、再接続した際の ack イベント
["subscribe", "channel", 1]を push。
Concurrent::Event+ インライン executor を使用し、Listener がポストした仕事(コールバック)が同期的に処理されるようにすることで、subscribe 確認が実際に届いたかどうかをテストから確実に観測可能にしている。- 新規テストクラス:
RedisAdapterTest::ListenerReconnection- 修正前: 2 秒タイムアウトで subscribe 確認が発火せず red
- 修正後: ~30ms 程度で確認を受信し green
- 既存の Redis 再接続系の統合テストを含む
actioncable/test/subscription_adapter/redis_test.rb全体も 26 テストすべてパス。
- 影響範囲・注意点
影響範囲
- 対象:
- Action Cable + Redis アダプタを利用しているアプリケーション。
- 特に、ネットワークが不安定な環境や Redis 接続が一時的に切断されうる状況で、
- クライアントが新しいチャネルに subscribe した直後に接続が落ちるケース。
- 期待される改善:
- 接続が落ちた直後に発行された subscribe が、再接続後に正しく confirm される。
- クライアント側で
confirmed?/subscribedコールバックが永久に呼ばれない「ハングした subscription」が事実上解消される。
トレードオフ / 残る可能性のある問題
PR 本文で言及されている既知のトレードオフ:
- シナリオ:
- チャンネル A に subscribe(コールバック登録) → ack 待ちの in-flight 状態。
- その後、接続が落ちる前にチャンネル A を完全に unsubscribe。
- その後で Redis 接続が落ちる →
reset→ 再接続 &resubscribe。
- この場合:
- A はすでに
@subscribersから削除されているのでresubscribe対象にならない。 - 一方で
@subscribe_callbacks内には A 用のコールバックが残り続ける可能性がある(実行も削除もされない)。 - 結果として、そのチャネル向けの小さな Proc オブジェクトがリークする。
- A はすでに
- PR 作成者はこれを「極めてレアなエッジケース」「リークするのは小さなコールバックだけ」として許容されるトレードオフだと位置付けており、
- もし気になる場合は、
reset時に「@subscribersに残っているチャネルの分だけコールバックを保持する」ような後続の改善が可能と述べています。
- もし気になる場合は、
運用上の注意:
- この PR 自体は バグフィックスのみ であり、外部 API や設定の互換性を壊すような変更はありません。
- 接続切断と再接続が頻繁に起こる環境では、今回の修正により subscribe 確認周りの安定性が向上するはずです。
- もし独自フォークで
Listener周りをカスタマイズしている場合は、@subscribe_callbacksをリセットしていない前提でコードを見直すとよいです。
- 参考情報 (あれば)
- 関連 PR:
- #57690: 「最後のチャネルが unsubscribe されたときにも Redis Listener を維持する」問題の修正。今回とは別のバグだが、同じ redis-client 書き換え由来。
- 関連コミット:
ef812c2652: Redis クライアント周りをredis-clientベースに書き換えたコミット。今回のリグレッションの起点。
- 実装の観点でのポイント:
- subscribe の ack を「チャネルごとの callback array で管理」し、ack 受信時にコールバック側で自分を削除する設計になっているため、「callback テーブルの一括初期化」は非常にデリケートな操作である、という教訓が得られる変更でもあります。
#57696 Respect an explicit id: nil in the Redis cable config
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
Action Cable の Redis アダプタにおいて、Redis 接続設定で明示的にid: nilを指定した場合にそれが上書きされず、そのまま尊重されるように修正した PR です。これにより、ドキュメント通り「id: nilを指定してCLIENT SETNAMEを発行しない」挙動が正しく動作するようになります。
- 変更内容の詳細
問題点
元の実装(ActionCable::SubscriptionAdapter::Redis.redis_connector)では、Redis 接続設定 config に対して ID を以下のように設定していました。
config[:id] ||= "ActionCable-PID-#{$$}"||= は「値が nil または false の場合に右辺で上書きする」演算子のため、
config[:id] = nilと明示的に nil を設定しても、「値がない」と判断されて "ActionCable-PID-#{PID}" が再度代入されてしまっていました。
しかし、Action Cable のドキュメント上は「id: nil を指定すると Redis の CLIENT SETNAME をスキップする(=クライアント名を付けない、もしくはプロキシ側に命名を任せる)」という仕様になっており、これが実際には効いていなかった、というバグです。
同じ ID を扱う別の箇所 SubscriptionAdapter::Base#identifier では、すでに「キーの存在有無」を使って nil を尊重する挙動になっていたため、実装の一貫性も欠けていました。
# イメージ: Base 側はこういう guard をしていた
config[:id] = ... unless config.key?(:id)修正内容
上記の不整合とバグを解消するため、redis_connector 側の ID 設定ロジックを以下のように変更しています。
- config[:id] ||= "ActionCable-PID-#{$$}"
+ config[:id] = "ActionCable-PID-#{$$}" unless config.key?(:id)ポイント:
config.key?(:id)で「キーが存在するか」を見るようにしたidキー自体が存在しない場合のみ、"ActionCable-PID-#{$$}"をセットするid: nilと明示されている場合は、そのままnilを保持する(=CLIENT SETNAMEを行わない)
これにより、Base 側と Redis コネクタ側で同じポリシー(「キーが存在しないときだけデフォルトを入れる」)になり、一貫性のある挙動になります。
テスト修正
既存のテスト RedisAdapterTest::ConnectorCustomIDNil は、「id: nil の時の挙動」をカバーしているつもりでしたが、実際には不十分でした。
- もともとのテストでは「テストヘルパーの
connection_idがnilであること」だけを確認しており、Redis クライアントの実際の ID 名 (client.id/CLIENT GETNAME相当) を検証していなかった - そのため、「
id: nilを指定したのに PID ベースの名前が設定されてしまう」バグを検出できていませんでした(テストは「たまたま」通っていた)
PR では、このテストを以下のように修正しています。
- 実際の Redis クライアントに対して
client.id(=CLIENT GETNAMEの結果)を取得し、それがnilであることをアサートする
これにより、id: nil が本当に Redis のクライアント名未設定(CLIENT SETNAME 未実行)として扱われることをテストで保証できます。
- 影響範囲・注意点
影響範囲
- 対象:
- Action Cable の Redis サブスクリプションアダプタを利用しているアプリケーション
- 特に
config.action_cable.cable = { adapter: "redis", id: nil, ... }のように明示的にid: nilを指定している構成
- 影響内容:
- これまで:
id: nilを指定していても、内部的には"ActionCable-PID-#{PID}"が設定され、CLIENT SETNAMEが実行されていた
- 修正後:
id: nilを指定した場合、CLIENT SETNAMEが行われず、クライアント名は未設定のままとなるidをまったく指定していない場合は、従来通り"ActionCable-PID-#{PID}"が自動的に設定される
- これまで:
実運用上の注意点
id: nilを前提にプロキシやミドルウェア側で命名している場合- これまで意図通り動いていなかった(Action Cable 側の PID ベース名前が付いていた)ケースでは、今回の修正でようやくプロキシ側の命名が使われるようになります。
- そのため、Redis モニタリング・接続監視ツールの表示やフィルタ条件が変わる可能性があります。
idを明示的に指定していないアプリケーション- 挙動は従来から変わりません。
- デフォルトの
"ActionCable-PID-#{$$}"付与は継続されるので、特別な対応は不要です。
idをnil以外で明示的に指定している場合- 例:
id: "my-custom-name" - こちらも挙動は変わりません(キーが存在するため、デフォルトは上書きされない)。
- 例:
- 参考情報 (あれば)
- 対象クラス・メソッド:
ActionCable::SubscriptionAdapter::Redis.redis_connectorActionCable::SubscriptionAdapter::Base#identifier
- 関連仕様:
- Redis
CLIENT SETNAME/CLIENT GETNAME - Action Cable の Redis adapter 設定での
idオプション:id省略時:"ActionCable-PID-#{PID}"が自動付与されるid: nil:CLIENT SETNAMEを呼ばない(クライアント名を設定しない)id: "string": 指定した名前でCLIENT SETNAMEが呼ばれる
- Redis
- PR 番号: #57696
- 変更ファイル:
actioncable/lib/action_cable/subscription_adapter/redis.rbactioncable/test/subscription_adapter/redis_test.rb
#57698 Coerce stream name to String in the test adapter accessors
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
Action Cable のテスト用アダプタ (SubscriptionAdapter::Test) において、シンボルのストリーム名を使ったときにブロードキャスト検証が正しく動かない不具合を修正する PR です。内部で文字列キーとして保存しているのに対し、読み出し側が生の引数(シンボルなど)でアクセスしていたため起きていた不整合を解消しています。
- 変更内容の詳細
問題の背景
Action Cable のブロードキャスト処理(Server::Broadcasting)では、メッセージを String(broadcasting) をキーにして保存しています。
一方、テスト用アダプタ (ActionCable::SubscriptionAdapter::Test) では、以下のようなアクセサを持っています:
def broadcasts(channel)
channels_data[channel] ||= []
end
def clear_messages(channel)
channels_data[channel] = []
endここで channel にシンボル :foo を渡した場合:
- 書き込み側:
String(:foo)→"foo"をキーに保存 - 読み出し側:
channels_data[:foo]を参照
となり、:foo と "foo" が別キーとして扱われるため、テストからは「メッセージがない」ように見えていました。
その結果:
assert_broadcasts(:foo, 1)
実際には"foo"に 1件保存されていても、:fooでは見つからず「0件」と判定される。assert_no_broadcasts(:foo)
実際には"foo"にメッセージがあっても、:foo側は空なので「ブロードキャストなし」と誤判定される(危険)。
修正内容
この PR では、テストアダプタ側のアクセサで、必ず文字列に変換してから内部ハッシュを参照するように変更しています。
def broadcasts(channel)
- channels_data[channel] ||= []
+ channels_data[String(channel)] ||= []
end
def clear_messages(channel)
- channels_data[channel] = []
+ channels_data[String(channel)] = []
endこれにより、以下が常に一致します:
- 書き込み:
String(broadcasting)(例::foo→"foo") - 読み出し:
String(channel)(例::foo→"foo")
そのため、テストコード側がシンボル・文字列どちらで指定しても正しくメッセージを取得・クリアできます。
テスト追加
TransmissionsTest(実際には actioncable/test/test_helper_test.rb)に以下の性質を確認するテストが追加されています:
test_assert_broadcasts_with_symbol_stream- 修正前:
assert_broadcasts(:test, 1) { ... }が「1 expected, but 0 were sent」で失敗(red) - 修正後: ブロードキャストを正しくカウントできて成功(green)
- 修正前:
テストは 14 回実行されており、安定して再現・解消できていることが確認されています。
- 影響範囲・注意点
影響範囲
- Action Cable のテストで
assert_broadcasts/assert_no_broadcastsなどを利用しているコードが対象です。 - 特に、ストリーム名を シンボルで指定しているテスト に直接影響します。
- 実動作(本番環境での Action Cable の挙動)には影響せず、テスト専用アダプタの挙動のみ変わります。
- Action Cable のテストで
何が変わるか
- これまで「テストが通っていたが、実際にはブロードキャストされていた」ケースが、正しく失敗するようになります。
- 具体例:ruby以前:
assert_no_broadcasts(:chat) do ActionCable.server.broadcast("chat", { msg: "hi" }) end:chatと"chat"の不一致で「ブロードキャストなし」と誤判定 → テストが通る
以後:"chat"にメッセージが載るのでテストが失敗 → 本来の期待どおり
- 具体例:
- これまで「テストが通っていたが、実際にはブロードキャストされていた」ケースが、正しく失敗するようになります。
注意点
- 既存テストで「たまたま」通っていたものが落ちる可能性がありますが、それは本来検知すべきバグ・回 regressions を正しく検出できるようになった結果です。
- テスト側の API 仕様としては、「ストリーム名はシンボルでも文字列でもよい」「どちらも同じ扱いを受ける」という意図に沿う変更なので、利用者視点からはむしろ直感的になります。
channels_dataを直接触っているようなメタなテスト・ツールがあれば、キーが文字列で統一される点を前提にしておく必要があります(とはいえ元々書き込み側は文字列だったため、実質的には仕様どおりになったとも言えます)。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57698
- 関連コード:
actioncable/lib/action_cable/subscription_adapter/test.rbServer::Broadcasting(ブロードキャスト時にString(broadcasting)を使っている箇所)
- CHANGELOG には記載なし(バグフィックス扱い)。
#57692 Coerce broadcasting to String in Channel#stop_stream_from
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
Action Cable のChannel#stop_stream_fromが、stream_fromと同様に引数をStringに変換して扱うよう修正されました。これにより、シンボルを使って購読開始・停止を行った場合でも、正しく購読解除されるようになります。
- 変更内容の詳細
問題の背景
ActionCable::Channel::Streams モジュール内で:
stream_fromは内部的に 常に String 化 してからstreamsハッシュに登録しています:rubydef stream_from(broadcasting, ...) broadcasting = String(broadcasting) streams[broadcasting] = ... endそのため、
stream_from :chatと書くと、内部的には"chat"というキーで管理されます。一方、
stop_stream_fromは 引数をそのまま キーとしてstreams.deleteに渡していました:rubydef stop_stream_from(broadcasting) streams.delete(broadcasting) end
その結果:
stream_from :chat
stop_stream_from :chatというペアで呼び出した場合、
- 登録時のキー:
"chat"(String) - 削除時のキー:
:chat(Symbol)
となり、ハッシュのキーが一致せず、streams.delete(:chat) は何も削除しません。
そのため pubsub.unsubscribe が呼ばれず、接続が生きている間ずっと購読が残り続けるバグが発生していました。
これは実際にテスト (SymbolChannel in stream_test.rb) でも stream_from :channel としてシンボルを使っているため、現実的なバグです。
修正内容
stop_stream_from の先頭で broadcasting を String に変換するようにし、stream_from と挙動を揃えました。
疑似コードとしては、今回の差分は以下のようなイメージです:
def stop_stream_from(broadcasting)
broadcasting = String(broadcasting) # ← ここが追加
streams.delete(broadcasting)
endこれにより、以下のようなコードが期待どおり動きます:
# 購読
stream_from :room_one
# 購読解除(同じシンボルを渡してもOK)
stop_stream_from :room_oneテスト追加
actioncable/test/channel/stream_test.rb にテストが追加されました。内容としては:
stream_from :room_oneで購読を開始stop_stream_from :room_oneで購読を停止- 最終的に購読者数(subscriber 数)が 0 になっていることを検証
という流れで、シンボルを使っても購読が正しく解除されることを確認しています。
- 影響範囲・注意点
影響範囲
- Action Cable の
Channel#stop_stream_fromを利用している全てのコードに影響します。 - とくに、
stream_from/stop_stream_fromに シンボルと文字列が混在して渡されていたケース で挙動が変わる可能性があります。- 例: これまでは「たまたま購読が解除されていなかった」コードが、今回の修正で正しく解除されるようになります。
- Action Cable の
期待される挙動の変化
- これまで:rubyのようなコードでは、購読が解除されず、コネクションが生きている限り配信を受け続けていた。
stream_from :chat stop_stream_from :chat - 修正後:
- 同じコードで、正しく購読解除 (
pubsub.unsubscribe) が行われる。 - メモリリークや不要なブロードキャスト受信が減る可能性があります。
- 同じコードで、正しく購読解除 (
- これまで:
注意点
stream_from/stop_stream_from両方で同じ値を渡しているつもりでも、「片方 Symbol / 片方 String」のようなコードがもしあった場合、そのコードは今後 正しく購読が解除される ようになります。- もし既存の挙動(バグ)に依存していた場合は影響がありますが、通常は正しい方向の修正です。
- カスタム実装で
streamsハッシュを直接触っている場合、キーの型がStringに統一される前提でコードを書いたほうが安全です。
- 参考情報 (あれば)
- 対象コード:
actioncable/lib/action_cable/channel/streams.rbActionCable::Channel::Streamsモジュール内のstream_from/stop_stream_from
- 関連するテスト:
actioncable/test/channel/stream_test.rb- 特にシンボルを引数に使う
SymbolChannelのテストケース付近が参考になります。
- 特にシンボルを引数に使う
- バグの性質:
- シンボル/文字列のキー不一致による購読解除漏れ (unsubscribe 漏れ)
- 典型的な「ハッシュキーの型不一致バグ」を、API の挙動を揃えることで解消した形です。
#57691 Iterate a snapshot of the connections in Server#each_connection
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
Action Cable のServer#each_connectionが、内部のハッシュを直接イテレートするのではなく「スナップショット(配列)」をイテレートするように変更され、接続の追加・削除が同時に起きてもRuntimeError: can't add a new key into hash during iterationが発生しないようになりました。これにより、ハートビートやサーバ再起動時の接続処理が安定して動作します。
- 変更内容の詳細
背景: どんな問題だったか
ActionCable::Server::Base の each_connection は、これまで connections_map.each_value を使って「生のハッシュ」を直接イテレートしていました。
一方で、以下の 3 つの処理がイベントループ / executor スレッド上で each_connection を呼んでいる一方、ワーカー側で add_connection / remove_connection が並行して走る構造になっています。
- 3 秒ごとのハートビートruby
executor.post { each_connection(&:beat) } - サーバ再起動時のクローズ処理ruby
each_connection { |c| c.close(...) } - 統計取得ruby
open_connections_statistics # 内部で each_connection.map(&:statistics)
Ruby の Hash はイテレーション中に新しいキーを追加すると RuntimeError: can't add a new key into hash during iteration を投げるため、connections_map.each_value 実行中に add_connection が走ると例外が発生し、以下のような不具合につながっていました。
- ハートビートが例外で中断し、古い/stale な接続がクリーンアップされない
- コードリロード時の再起動スイープが途中で止まり、接続が閉じられない
これは、接続ストアを Array から Hash に変更したコミット(8ed78693c5)以降のリグレッションです。
Array はイテレーション中に << しても例外にならない一方、Hash はキー追加に厳密なので挙動が変わってしまった形です。
修正内容: スナップショットをイテレートする
each_connection の実装を変更し、connections_map を直接イテレートせず、「スナップショット配列」を経由するようにしました。
変更ポイントは以下の通りです(概念的なイメージ):
# 変更前(イメージ)
def each_connection(&block)
if block_given?
connections_map.each_value(&block)
else
connections_map.each_value
end
end
# 変更後(イメージ)
def each_connection(&block)
if block_given?
connections.each(&block) # connections は connections_map.values を返す
else
connections.each # Array#each なので Enumerator もスナップショットに対するもの
end
endconnections メソッドは既に実装済みで、内部的には connections_map.values を返すだけのメソッドです。
これにより、each_connection は常に「現在の connections_map の値をコピーした配列」に対して each するようになります。
これで、イテレーション中に add_connection が connections_map に新しいエントリを追加しても、each の対象は既に取得済みの配列であり、Hash の「イテレーション中にキー追加」には該当しないため、例外が発生しなくなります。
ブロックなし呼び出しも維持
open_connections_statistics のように、each_connection をブロックなしで呼び出して Enumerator を受け取るケースもあります:
each_connection.map(&:statistics)Array#each は Enumerator を返すため、each_connection の戻り値もそのまま Enumerator となり、
既存コードはインターフェースを変えずに動作します。
この Enumerator も、connections_map のスナップショット配列に対するものなので安全です。
テストの追加
回帰を防ぐためにテストが追加されています(actioncable/test/server/base_test.rb)。
テストの内容(要約):
- ある接続の
#beatメソッド内でadd_connectionを呼び出すようにセットアップ each_connection(&:beat)を実行- その際に
RuntimeErrorが発生しないことをアサートする
これにより、each_connection のイテレーション中に add_connection が走る典型的な競合パターンがカバーされます。
- 影響範囲・注意点
対象コンポーネント
- Action Cable サーバ (
ActionCable::Server::Base) の接続管理まわり - 具体的には、ハートビート、接続クローズ処理、接続数/統計取得など
each_connectionに依存する箇所
- Action Cable サーバ (
挙動面の変化
each_connectionは「呼び出し時点の接続一覧」をスナップショットとして処理するようになります。- イテレーション中に新たに追加された接続は、その回の
each_connectionでは処理されません(次回以降の呼び出しで処理される)。 - イテレーション中に削除された接続がスナップショット側に含まれている場合、すでにクローズ済みかどうかを呼び出し側で考慮する必要がありますが、従来コードも実質的には同種の競合を抱えていたため、実務上の差分は小さいと考えられます。
メリット
- 高負荷時でも、ハートビートやコードリロード時の再起動処理が Hash まわりの
RuntimeErrorによって中断されなくなります。 - マルチスレッド環境で Action Cable を使っているアプリケーションにおいて、
「たまにハートビートで例外が出て stale connection が残り続ける」といった問題が解消される可能性があります。
- 高負荷時でも、ハートビートやコードリロード時の再起動処理が Hash まわりの
パフォーマンス面の影響
each_connection呼び出し毎にconnections_map.valuesで配列コピーが発生します。- 接続数が非常に多い(数万オーダー)環境では、このコピーコストがわずかに増える可能性がありますが、
- もともとのハートビートは 3 秒毎
- 再起動・統計取得も高頻度の処理ではない
ため、安定性向上とのトレードオフとしては妥当と考えられます。
- 既に
connectionsメソッド自体が同じコピー処理をしており、それを使うようにしただけなので、新しく増えたコストは実質ありません。
- 参考情報 (あれば)
- 回帰の原因となったコミット:
8ed78693c5- 接続ストアを Array から Hash に変更したことで、Hash への追加時の制約に引っかかるようになった。
- Ruby の仕様:
Hash#each等でイテレート中に新しいキーを追加するとRuntimeError: can't add a new key into hash during iterationが発生する。Array#eachはイテレーション中の<<に対して例外を出さない(ただし、同じループで新規要素が処理されるかどうかは別)。
#57675 Don't mutate the caller's string in Inflector#transliterate
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
ActiveSupport::Inflector.transliterateが、非 frozen かつ非 UTF-8 の文字列引数を「こっそり破壊的変更」してしまう不具合を修正し、常に引数を複製してから処理するようにした PR です。これによりparameterizeも含め、呼び出し元の文字列オブジェクトのエンコーディングやバイト列が書き換わらないことが保証されます。
- 変更内容の詳細
問題の内容
ActiveSupport::Inflector.transliterate は内部で以下のような処理を行います(概略):
- 引数が ASCII のみの場合は早期
return string.dup(このパスは以前から安全)。 - それ以外の場合は、
force_encodingやencode!を呼んで UTF-8 に揃えたうえで、近似のローマ字表現などに変換。
問題は、「frozen でない非 UTF-8 文字列」を渡した場合に起きていました。
s = "中文".encode(Encoding::GB18030)
s.frozen? # => false
ActiveSupport::Inflector.transliterate(s)
# ここで本来は s は変化しない想定だが…
s.encoding # expected: GB18030, actual: UTF-8 # ← encoding が書き換わっている原因:
- 以前の実装では「frozen なら dup してから処理する」が、「非 frozen ならそのまま
force_encoding/encode!」というパスが存在。 - そのため、非 frozen の引数については、呼び出し側が渡した同じオブジェクトに対して エンコーディング変更・バイト列の再エンコードが走っていた。
この挙動は Ruby/ActiveSupport でよくある「非破壊メソッド(見た目)」の期待に反し、parameterize も transliterate を内部で使うため、同様に「引数を破壊的に変更する」副作用を持っていました。
修正内容
transliterate の中で、「frozen のときだけ dup する」ロジックを「常に dup する」ように変更しました。
以前(イメージ):
def transliterate(string, ...)
return string.dup if string.ascii_only?
# ここで frozen の場合のみ dup
string = string.dup if string.frozen?
string.force_encoding(Encoding::UTF_8)
string.encode!(...)
...
end修正後(イメージ):
def transliterate(string, ...)
return string.dup if string.ascii_only?
# frozen かどうかに関係なく必ず dup
string = string.dup
string.force_encoding(Encoding::UTF_8)
string.encode!(...)
...
endポイント:
- 早期リターンの
return string.dup if string.ascii_only?はそのまま。 - それ以外のパスに入った時点で「必ず dup 済みオブジェクト」に対して
force_encoding/encode!を行うようになったため、呼び出し側が持っている元の文字列は一切触られない。
テストの追加
activesupport/test/transliterate_test.rb に 3 つの回帰テストが追加されています。
- 非 frozen な GB18030 文字列を
transliterateに渡しても、元の文字列の encoding / bytes が変わらないこと。 - 非 frozen な US-ASCII(中身は非 ASCII バイト)を渡しても、同様に encoding / bytes が変わらないこと。
parameterizeに GB18030 文字列を渡しても、元の encoding が変わらないこと。
これらは修正前は「encoding が UTF-8 に書き換わる」ために失敗し、修正後は全て成功します。
- 影響範囲・注意点
挙動の本来の期待値に揃える後方互換修正
表面的には「バグ修正」であり、transliterate/parameterizeが非破壊であるという一般的な期待に沿うものです。ほとんどのコードにとっては望ましい・安全な変更です。パフォーマンス面の影響(軽微だが存在)
これまで「非 frozen かつ非 UTF-8」の場合はそのままのオブジェクトを使っていたところを、必ずdupするようになりました。transliterate/parameterizeを非常に大量・高頻度に呼んでいる場合は、オブジェクト生成コスト・GC にごく僅かな影響が出る可能性があります。- ただし、既に「frozen 文字列」で呼ばれるケースでは元々 dup しており、また ASCII のみ文字列はもともと
string.dupで早期 return していたため、新規に増えたのは「非 frozen かつ非 UTF-8」のパスのみです。
「暗黙の破壊的挙動」に依存していたコードは影響を受ける
正常な設計ではありませんが、万が一「transliterateに渡すと encoding が UTF-8 に書き換わる」という副作用に意図的・無意識に依存していたコードがある場合、- 今後は「呼び出し元の文字列は元の encoding のまま」になります。
- 必要なら
string.encode!(Encoding::UTF_8)のように「自分で明示的に」変換すべきです。
マルチバイト・非 UTF-8 ロケール環境での安定性向上
GB18030 や Shift_JIS などの非 UTF-8 文字列を扱うコードベースでも、transliterate/parameterize呼び出しが元のデータを壊さないことが保証されるため、バグの温床が一つ減ります。
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/57675
- 関連コミット(過去の部分修正):
5623f7f1a3- frozen + ascii_only のケースに対する guard を追加していたが、今回の「非 frozen 非 UTF-8」パスはカバーしていなかった。
- 関連メソッド:
ActiveSupport::Inflector.transliterateString#parameterize(ActiveSupport拡張)
#57676 Fix StructuredEventSubscriber.debug_only leaking across subscriber subclasses
マージ日: 2026/6/13 | 作成者: @55728
- 概要 (1-2文で)
ActiveSupport::StructuredEventSubscriberのdebug_onlyで設定したメソッド名が、サブクラス間や基底クラスに「漏れ」て共有されてしまうバグを修正する PR です。class_attributeのデフォルト配列を破壊的に変更していたために起きていた問題を、非破壊的な再代入に変えることで解消しています。
- 変更内容の詳細
問題のポイント
ActiveSupport::StructuredEventSubscriberではdebug_methodsが次のようにclass_attributeで定義されている(実際の定義は省略):rubyclass_attribute :debug_methods, default: []debug_only :method_nameを呼ぶと、このdebug_methodsにメソッド名を追加して「デバッグ環境のみでサイレンスするイベント」を指定する仕組み。- しかし、実装が次のように破壊的追加になっていた:ruby
self.debug_methods << method class_attributeのdefault: []は 1 個の同じ配列オブジェクトを基底クラスとサブクラスで共有するため、「破壊的に push する」と全クラスで同じ配列が書き換えられてしまう。
その結果:
sx = Class.new(ActiveSupport::StructuredEventSubscriber) { def foo(e); end; debug_only :foo }
sy = Class.new(ActiveSupport::StructuredEventSubscriber) { def bar(e); end; debug_only :bar }
sx.debug_methods # => [:foo, :bar] # 本当は [:foo] が期待
sy.debug_methods # => [:foo, :bar] # 本当は [:bar] が期待
ActiveSupport::StructuredEventSubscriber.debug_methods # => [:foo, :bar] # 本当は [] が期待- さらに深刻なのは、ユーザーが自分の
StructuredEventSubscriberにたまたま:sqlや:render_templateなどフレームワーク側と同名のメソッドを持っていると、そのイベントが「debug_only として扱われてしまい本番環境で黙ってしまう」可能性がある点。
修正内容
debug_only 内の 1 行だけを修正:
- self.debug_methods << method
+ self.debug_methods += [method]意味:
<<は既存配列オブジェクトを破壊的に変更する。+= [method]は「新しい配列を作って代入」するので、class_attributeの writer 経由でクラスごとに独立した配列がセットされる。class_attributeはサブクラスで書き込みが行われたタイミングで、そのクラス専用の値を持つようになるため、この書き方で「サブクラスごとに別々のdebug_methods」を持てる。
この方針は、同じく class_attribute を使っている ActiveSupport::LogSubscriber.subscribe_log_level の実装(self.log_levels = log_levels.merge(...))と同じノンミュータブルな書き方にそろえたものです。
テストの追加・修正
新規テスト:
test_debug_only_does_not_leak_across_subclasses目的:
- 2 つの異なるサブクラスそれぞれが
debug_onlyしたメソッド名のみを、自分のdebug_methodsに持つこと。 - 基底クラス
ActiveSupport::StructuredEventSubscriberのdebug_methodsは空のままで汚染されないこと。
検証内容(概念的にはこういうことをチェック):
rubysx = Class.new(ActiveSupport::StructuredEventSubscriber) do def foo(e); end debug_only :foo end sy = Class.new(ActiveSupport::StructuredEventSubscriber) do def bar(e); end debug_only :bar end assert_equal [:foo], sx.debug_methods assert_equal [:bar], sy.debug_methods assert_equal [], ActiveSupport::StructuredEventSubscriber.debug_methods- 修正前は
[:debug_only_event, :foo, :bar]のようにすべて混ざってしまい、テストが落ちる。 - 修正後は期待通りクラスごとに分離されてテストが通る。
- 2 つの異なるサブクラスそれぞれが
既存テスト
test_debug_only_methodsの修正:- もともとは「基底クラスにサブクラスの
debug_onlyが漏れている」ことに依存してテストが通っていた。 - 今回の修正によりその前提が成り立たなくなるため、テスト対象を「実際に
debug_onlyを宣言しているTestSubscriberサブクラス」側に付け替えた。 - さらにテストの teardown に
detach_from :testを追加し、ActiveSupport::Notificationsへの subscription を後始末するようにしている。これによりテスト順序に依存しない(サブスクが他のテストに影響しない)ようになる。
- もともとは「基底クラスにサブクラスの
- 影響範囲・注意点
対象:
ActiveSupport::StructuredEventSubscriberを直接 / 間接的に継承し、debug_onlyを使っている全てのコード。
仕様面の挙動変化:
- これまで「意図せず他サブスクライバの
debug_only設定が効いてしまっていた」ケースが、本来の期待通り効かなくなる(= バグ修正)。 - 特に、Rails 本体の
StructuredEventSubscriber(例: SQL, rendering まわり)が設定しているdebug_onlyが、ユーザー定義サブクラスに影響することはなくなる。
- これまで「意図せず他サブスクライバの
実運用で起こりうる変化:
- もし既存コードが「たまたまバグに依存していた」場合、たとえば:
- A サブスクライバが
debug_only :foo - B サブスクライバも
def foo(e); endを持っているがdebug_onlyは呼んでいない - それでも B の
fooイベントが本番で出力されない(A のdebug_onlyが漏れていた)
- A サブスクライバが
- 今後は B 側には
debug_onlyが効かなくなり、本番環境でもfooが通知されるようになる。 - これは意図された挙動への修正だが、「出力が増えた」と感じる場面があるかもしれないので、ログ周りで挙動が変わったと感じた場合はこの修正を疑うとよいです。
- もし既存コードが「たまたまバグに依存していた」場合、たとえば:
パフォーマンス:
+= [method]によって小さい配列のコピーが行われる程度で、通常の利用では誤差レベルと考えてよいです。class_attributeの「書き込み時にクラス単位でコピーを作る」という設計に沿った使い方になったため、むしろ一貫性は向上しています。
- 参考情報 (あれば)
関連する Rails の仕組み:
ActiveSupport::StructuredEventSubscriber- 構造化されたイベント(通知)を購読し、特定のメソッド名をハンドラとして扱う仕組み。
debug_onlyで指定したメソッドは、開発/テスト環境では実行するが、本番ではサイレンスするなどの制御に利用される。
class_attribute- クラスレベルのアクセサを定義し、サブクラスごとに上書き可能な「継承されるクラス変数」のような仕組み。
- デフォルト値がミュータブル(
[]や{})な場合は、破壊的操作を避け、代入で上書きするのが定石。
類似コード:
ActiveSupport::LogSubscriber.subscribe_log_levelでも同様のパターンで:rubyのように破壊的操作ではなく再代入している。self.log_levels = log_levels.merge(...)
この PR により、debug_only の振る舞いがクラスごとにきちんと独立し、意図しないイベントサイレンスが起きづらくなります。
#57693 Make ActiveRecord::Base.with call Object#with when passed a block
マージ日: 2026/6/13 | 作成者: @janko
- 概要 (1-2文で)
ActiveRecord::Base の.withがブロック付きで呼ばれた場合に、CTE 用のActiveRecord::Relation#withではなく、Ruby コア拡張のObject#withを呼ぶようにした変更です。これにより、Active Record モデルでwithを使って一時的に設定を上書きするパターンが自然に利用できるようになります。
- 変更内容の詳細
これまでの挙動
ActiveRecord::Base.with は ActiveRecord のクエリ用 DSL における CTE (Common Table Expression, WITH 句) を構築するためのメソッドとして、ActiveRecord::Relation#with に委譲していました。
# 例: CTE を使ったクエリ
User.with(active_users: User.where(active: true))
.from("active_users")
.where(...)Relation#with はクエリ構築専用のため、ブロックは受け付けませんでした。
そのため、以下のように「オブジェクトを特定のオプションと一緒にブロックに渡す」という Object#with 的な使い方はできませんでした。
# 想定していたが動かなかった使い方 (ブロックを渡すとエラー/無視される)
ActiveRecord::Base.with(logger: Logger.new($stdout)) do
# ...
end今回の変更点
ActiveRecord::Base.with を呼び出した際に ブロックが渡されているかどうか を判定し、挙動を切り替えるようになりました。
- ブロックなし: 今まで通り、クエリ用の
Relation#withを呼び出して CTE を構築する - ブロックあり: CTE ではなく、
Object#withを呼び出す(core extension がロードされている場合)
Rails には ActiveSupport のコア拡張として Object#with が存在し、典型的には以下のような振る舞いをします(概念的な例):
object.with(foo: 1, bar: 2) do
# object に foo/bar が一時的に適用された状態でブロック実行
end今回の変更により、ActiveRecord::Base もこのパターンに従うようになります。
サンプル: 一時的に logger を差し替える
PR の説明にある典型例:
ActiveRecord::Base.with(logger: Logger.new($stdout)) do
# このブロック内だけ SQL ログを標準出力に出す
User.create!(...)
User.first
end
# ブロックを抜けると、元の logger に戻るこれはテスト時に一時的に SQL ログを有効化したい/別の出力先に切り替えたいときなどに便利です。
実装上のポイント
activerecord/lib/active_record/querying.rb内のBase.withの実装が修正され、block_given?を見て処理を分岐するようになっています。- ブロック付き呼び出しで
Object#withを呼ぶのは、Enumerable#find・Enumerable#selectと ActiveRecord のfind・whereの関係と同様のポリシーに沿っています。- すなわち、「ブロックがあるときは Ruby/Enumerable 側の意味を優先する」という一貫した挙動です。
- テスト (
relation/with_test.rb等) が追加・更新され、ブロック付き.withがObject#withを呼んでいることが検証されています。 activerecord/CHANGELOG.mdにこの挙動変更が明記されています。
- 影響範囲・注意点
影響範囲
- 影響を受けるのは
ActiveRecord::Base.withを「ブロック付き」で呼んでいるコード だけです。 - 既に大半の CTE 用コードはブロックなしで
.withを使っているはずなので、その場合は挙動は一切変わりません。
注意点
ブロック付き
.withで CTE は作れない
今回の仕様により、ブロック付き.withは「常にObject#withを意図したもの」と解釈されます。
「ブロック付きで CTE を何かしら操作したい」ような使い方はできません。Object#withコア拡張のロードが前提- ActiveSupport の core extensions(特に
Object#with)がロードされていない環境では、ブロック付き.withがNoMethodErrorになる可能性があります。 - Rails 標準のアプリケーション構成であれば通常はロードされているため問題になりにくいですが、ミニマル構成・一部のエンジン等では注意が必要です。
- ActiveSupport の core extensions(特に
メソッド解決の優先度の理解が必要
.withというメソッド名は、Active Record (CTE) 用と Object コア拡張の両方で使われているため、「ブロックの有無で意味が変わる」点をチームで共有しておいた方がよいです。whereとfindがブロック有無で Enumerable とクエリメソッドを切り替えるのと同じノリですが、withは使用頻度が低く気付きづらい可能性があります。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57693
- 同系統の挙動:
Model.where { ... }→ Enumerable のselect的意味 (ブロック) と Relation のwhere(ハッシュ・引数) を使い分けModel.find { ... }→ Enumerable のfindにフォールバック
- 用途イメージ:
- 一時的なログ出力設定 (
logger) - 一時的に
ignored_columnsやdefault_scopes相当のカスタムアクセサを変更 - 特定のテストケースでのみ設定を上書きして実行、など
- 一時的なログ出力設定 (
#57689 Add test coverage for HashWithIndifferentAccess key access and except
マージ日: 2026/6/13 | 作成者: @hammadxcm
- 概要 (1-2文で)
ActiveSupport::HashWithIndifferentAccessについて、これまでテストされていなかった分岐や挙動(fetchのブロック・デフォルト値、values_atの欠損キー、digの miss パス、except/withoutの基本仕様)に対してテストが追加されました。アプリケーションコードへの変更はなく、テスト追加のみのPRです。
- 変更内容の詳細
このPRでは activesupport/test/hash_with_indifferent_access_test.rb に 33 行のテストコードが追加されています。主な対象は以下の4点です。
2.1 fetch の未テスト分岐(デフォルト値・ブロック・*extras)
追加でカバーされた挙動:
fetchに デフォルト値 を渡した場合の挙動fetchに ブロック を渡した場合の挙動HashWithIndifferentAccess独自の「キーを String に変換したうえでブロックへ渡す」挙動fetchの*extras引数(ブロックに追加引数を渡すパススルー)の挙動
イメージとしては以下のようなテストが追加されていると考えられます:
h = ActiveSupport::HashWithIndifferentAccess.new(a: 1)
# デフォルト値
assert_equal 1, h.fetch(:a, 10) # 既存キー => 値
assert_equal 10, h.fetch(:b, 10) # 欠損キー => デフォルト値
# ブロック(キーは String で渡される)
value = h.fetch(:b) { |key, extra| [key, extra] }
assert_equal ["b", :foo], value # key は "b"(String)、extra は *extras 経由※ 実際のテストコードの細部は異なる可能性がありますが、
- ブロックに渡るキーが String 化されること
*extrasをきちんと透過していること- ドキュメントどおりの挙動が保証されること
を確認するテストが追加されています。
2.2 values_at の欠損キー時の挙動
追加でカバーされた挙動:
values_atに存在しないキーを渡したとき、その位置にnilが入ることfetch_valuesは同じ状況で例外を投げる(という仕様との対比)
例:
h = ActiveSupport::HashWithIndifferentAccess.new(a: 1)
assert_equal [1, nil], h.values_at(:a, :b) # :b は存在しない => nil
# fetch_values(:a, :b) は KeyError を raise する想定これにより、values_at の「欠損は nil 埋め」という挙動が仕様どおりであることを回帰テストとして担保しています。
2.3 dig の欠損キー時の挙動(miss パス)
追加でカバーされた挙動:
digが存在しないキーを辿ろうとした際にnilを返す、というドキュメント記載の miss パス
既存テストでは、dig が成功して値を返すパスのみカバーされていましたが、今回:
h = ActiveSupport::HashWithIndifferentAccess.new(a: 1)
assert_nil h.dig(:zoo) # ドキュメント例: counters.dig(:zoo) # => nilのような「キーが存在しない場合」の挙動をテストしています。HashWithIndifferentAccess かつ dig という組み合わせで、シンボル/文字列のどちらでも miss 時は nil を返すことが保証されます。
2.4 except / without の基本仕様(これまでテストなし)
今回もっとも大きい追加カバレッジ:
except/withoutが まったくテストされていなかった ため、以下を確認するテストが追加されました。
カバーされる挙動:
新しい
HashWithIndifferentAccessを返す- レシーバとは別オブジェクトであること
- 戻り値のクラスが
HashWithIndifferentAccessであること
文字列キー・シンボルキーを混在して受け取れる
:aや"a"を指定しても、同じキーとして扱われる
元のハッシュは変更されない (non-destructive)
except/withoutの呼び出し後もレシーバはそのまま
例:
h = ActiveSupport::HashWithIndifferentAccess.new(a: 1, b: 2, "c" => 3)
h2 = h.except(:a, "b")
assert_instance_of ActiveSupport::HashWithIndifferentAccess, h2
assert_equal({ "c" => 3 }, h2) # :a, "b" が除外された新しいハッシュ
assert_equal 3, h2[:c] # indifferent access 維持
assert_equal 1, h[:a] # 元の h は変わらない
assert_equal 2, h[:b]
assert_equal 3, h[:c]without は except のエイリアスであるため、同等の挙動を確認するテストも含まれていると考えられます。
- 影響範囲・注意点
影響範囲
- 変更はテストファイル(
hash_with_indifferent_access_test.rb)のみで、本体コードには一切変更がありません。 - 既存アプリケーションの挙動には影響しませんが、今後これらの挙動を変えるとテストが落ちるようになりました。
- 特に
except/withoutはこれまでテストがなかったため、将来のリファクタリング時に仕様変更を誤って導入しにくくなります。
- 変更はテストファイル(
注意点
- このPRは「ドキュメントされている/既にそうであると仮定されていた」挙動を、テストとして明示的に固定化したものです。
- 既に
HashWithIndifferentAccess#fetchのブロック引数として「文字列キーが来る」前提でコードを書いている場合、その前提が今後も破られにくくなった一方、「実はシンボルになるべき」といった解釈変更はしづらくなります。 except/withoutが常に非破壊でHashWithIndifferentAccessを返すことが仕様として固まるため、これと異なる期待(たとえば破壊的に動いてほしい等)は今後も満たされません。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57689
- 関連ドキュメント(HashWithIndifferentAccess):
https://api.rubyonrails.org/classes/ActiveSupport/HashWithIndifferentAccess.html HashWithIndifferentAccess#digのドキュメント例:counters.dig(:zoo) # => nilという miss パスが公式に例示されています。
#57680 Ensure assert_no_changes isn't given a static value
マージ日: 2026/6/12 | 作成者: @amomchilov
- 概要 (1-2文で)
assert_no_changes(および関連メソッド)が「静的な値」を引数に取った場合に、対象が実際には変化していてもテストが常に成功してしまう問題を防ぐため、引数に許可される型を制限し、そうでない場合はArgumentErrorを発生させるようにした変更です。これにより、書き間違い・勘違いによるテストの空振りを早期に検知できるようになります。
- 変更内容の詳細
何が問題だったか
これまでの assert_no_changes は、第一引数に任意のオブジェクトを受け取り、それをそのまま評価対象として扱っていました。
a = []
assert_no_changes(a.size) do
a << "something"
end上記のように誤って「値そのもの」(この例だと 0)を渡すと、
- ブロック実行前: 評価対象 =
0 - ブロック実行後: 評価対象 =
0(元のオブジェクトはすでに固定値)
となるため、「変化なし」と判定されてテストが成功してしまいます。
本来は下記のように「値を返す処理」か「評価可能な Ruby コード」を渡す必要があります。
assert_no_changes(-> { a.size }) do
a << "something"
end
# または
assert_no_changes("a.size") do
a << "something"
end今回の変更内容
この PR では、assert_no_changes が受け取る「監視対象 expression」に対して、以下の制約を導入しました。
- 許可される型:
- Callable(通常は
Proc/lambda/->) StringSymbol
- Callable(通常は
- 上記以外のオブジェクトを渡すと
ArgumentErrorを投げる
擬似コードで表すと、イメージは以下のようなチェックです(実装そのものではなく概念的なもの):
def assert_no_changes(expression, &block)
unless expression.respond_to?(:call) || expression.is_a?(String) || expression.is_a?(Symbol)
raise ArgumentError, "assert_no_changes requires a callable, String, or Symbol expression"
end
# 従来通りの比較ロジック
endつまり、次は OK:
assert_no_changes(-> { user.reload.name }) { ... }
assert_no_changes("user.reload.name") { ... }
assert_no_changes(:some_method_name) { ... } # 実装依存だが概ね許容次は NG (ArgumentError):
assert_no_changes(0) { ... }
assert_no_changes([]) { ... }
assert_no_changes(Object.new) { ... }追加テスト
activesupport/test/test_case_test.rb にテストが追加され、主に次の点が確認されています。
Proc/String/Symbolを渡した場合に従来通り動作することIntegerなど静的値を渡した場合にArgumentErrorが発生すること- これまで「たまたま
to_sで Ruby コードになる」ような特殊なオブジェクトは、今後はArgumentErrorになること
また CHANGELOG.md に、この動作変更が明記されています。
- 影響範囲・注意点
既存コードへの影響
- 影響を受けるのは、
assert_no_changes(および同様の実装がある「友達メソッド」)に対して、- 第一引数に「callable でも String でも Symbol でもないオブジェクト」を渡しているテストだけです。
- 特によくある誤用パターンとしては:
- 「メソッド呼び出し結果」をそのまま渡してしまっているケース
# NG: 呼び出し結果を渡している
assert_no_changes(user.reload.name) do
user.update!(name: "Bob")
end
# OK: 呼び出しを遅延させる
assert_no_changes(-> { user.reload.name }) do
user.update!(name: "Bob")
end
# あるいは
assert_no_changes("user.reload.name") do
user.update!(name: "Bob")
end「静的な String / Symbol」の限界
PR でも明記されていますが、この変更はあくまで「明らかな誤用」を防ぐものであり、完全に誤用を排除できるわけではありません。
String/Symbolは「Ruby コードとしてevalされるもの」とみなされるため、- 静的な文字列でも
evalが成功する場合は、そのまま通ってしまう可能性があります。
- 静的な文字列でも
- ただし、ほとんどの「ただの文字列」は Ruby として評価すると
NameErrorなどになるので、サイレントに通り抜けるケースは少ないだろう、という判断です。
例:
oncall = "Alice"
assert_no_changes(oncall) do # => NameError: uninitialized constant Alice
oncall = "Bob"
end以前「たまたま動いていた」変なコードの破壊的変更
PR の例:
class Hmm
def to_s = "x" # 下の文脈では eval 可能
end
def test_hmm
x = 1
assert_no_changes(Hmm.new) do
# no change
end
end- 以前:
Hmm.new.to_s #=> "x"がevalされて(たまたま)動いていた - 今後:
Hmm.newは callable/String/Symbol ではないのでArgumentError
もし本当にこのような(かなりレアな)コードを書いていた場合は、素直に to_s を呼ぶようにすることで回避できます。
assert_no_changes(Hmm.new.to_s) do
# ...
end- 参考情報 (あれば)
- 対象メソッド:
ActiveSupport::Testing::Assertions#assert_no_changes(およびフレンドメソッド) - 典型的な正しい使い方(改めて):
# ブロック実行で user.name が変わらないことを検証したい場合
# 推奨(lambda / Proc)
assert_no_changes(-> { user.reload.name }) do
do_something
end
# もしくは String(eval ベース)
assert_no_changes("user.reload.name") do
do_something
end- 今回の変更は「テストが静かに間違った成功をする」パターンを減らすための、安全性を高めるための軽い破壊的変更と言えます。
#57057 Report Postgres FATAL exceptions more clearly
マージ日: 2026/6/12 | 作成者: @matthewd
- 概要 (1-2文で)
PostgreSQL から返されるFATALエラーを、一般的なStatementInvalidではなく「接続が壊れた」ことを示すConnectionFailedとして明示的に扱うように変更し、壊れた接続を後からまで引きずらないようにした PR です。
そのために libpq の使い方をexec_params依存から、send_query_params+ 明示的なget_resultループや notice receiver の活用に変更し、より確実に「本当の例外」を捕捉できるようにしています。
- 変更内容の詳細
2-1. 何を解決しようとしているか
PostgreSQL の FATAL エラーは「サーバー側で接続を終了する」ことを意味するため、その後のコネクションは使い物になりません。
しかし従来の Active Record では、FATAL も他の SQL エラーと同じように StatementInvalid として扱われるケースがあり、その結果:
- 壊れたコネクションがプールに残る
- 次のクエリ実行時にはじめて接続切断に気付く
という遅延的かつ分かりにくい障害につながる可能性がありました。
この PR は:
FATALエラーを検知したら、その場でConnectionFailed相当として扱い直し- コネクションプール側からも壊れたコネクションとして扱えるようにする
ことで、より早く・明示的に障害を表面化させる狙いがあります。
2-2. PostgreSQL アダプタ側の主な変更点
2-2-1. exec_params から send_query_params + 明示的 get_result へ
これまで多くのパスで使われていた raw_connection.exec_params(...) をやめ、以下の形に近い実装に変えています:
raw_connection.send_query_params(sql, param_types, param_values, result_format)
raw_connection.set_single_row_mode if need_single_row # 例: streaming 的なケース
results = []
loop do
result = raw_connection.get_result
break unless result
# ここで result.status やエラー情報を精査する
results << result
endポイント:
exec_paramsは内部でsend_query_params+get_last_result相当を実行する「まとめメソッド」ですが、その内部実装に依存していると、「最後の結果」だけからではFATALなど一部の重要なエラーを取りこぼす可能性があります。- 明示的に
get_resultをポーリングすることで:- 通常のエラー
FATALエラーNOTICE/WARNINGなど をより正確かつタイムリーに検出できます。
これに合わせて、PostgreSQL アダプタ内のクエリ実行系 (execute, prepared statement 実行など) が、この新しいパターンに書き換えられています。
2-2-2. FATAL エラーを ConnectionFailed にマッピング
PostgreSQL のエラー情報から severity (もしくは error_field の PG_DIAG_SEVERITY / PG_DIAG_SEVERITY_NONLOCALIZED) を取り出し、それが FATAL / PANIC の場合には、通常の StatementInvalid ではなく「接続失敗系」の例外に変換します。
擬似的には以下のようなイメージです:
case severity
when "FATAL", "PANIC"
raise ActiveRecord::ConnectionFailed, original_error.message
else
raise ActiveRecord::StatementInvalid, original_error.message
end実際には、ActiveRecord の例外ラッパ (translate_exception / translate_exception_class 的なメソッド) の中で振り分けが行われます。
2-2-3. Notice receiver からの FATAL も検知
PostgreSQL / libpq には、エラーや notice を「結果オブジェクトとして返す」以外に、「notice receiver 経由で非同期的に通知する」経路があります。
libpq の内部状態によっては:
- クエリ実行結果としては成功 / 何も返さない
- しかし notice receiver 経由で
FATALが届く
というパターンも起こり得ます。この PR では:
- PostgreSQL アダプタで notice receiver を設定
- そこに届いたメッセージから
FATAL/PANICを検知 - 「本当は接続が死んでいる」ことを把握し、例外変換時に考慮
するようにしています。
これにより、libpq が「直接のクエリ結果として返さなかった FATAL」も漏れなく拾えるようになります。
2-3. 抽象アダプタ (abstract_adapter.rb) の変更
abstract_adapter.rb には例外変換と接続状態管理の共通処理が含まれています。
主な変更点:
- 例外クラスを決めるロジックに「接続が壊れていると判断できる場合は
ConnectionFailedにする」分岐を強化。 - PostgreSQL アダプタが「これは
FATALだった」とわかるような情報を渡し、それを尊重して適切な ActiveRecord 例外にマッピングする。
これにより、DB ごとに異なる「致命的エラー」の検出方法をアダプタ側が実装し、抽象アダプタはそれを統一的な ActiveRecord 例外クラスに落とし込む、という役割分担が明確化されています。
2-4. テストの追加・変更
変更されたテストファイル:
activerecord/test/cases/adapter_test.rbactiverecord/test/cases/adapters/postgresql/postgresql_adapter_test.rbactiverecord/test/cases/adapters/postgresql/statement_pool_test.rb
テストで主に担保していること:
FATAL相当のエラーが発生したとき、ActiveRecord::ConnectionFailedが発生すること- その後の接続が再利用されず、適切に再接続 / プールからの切り離しが行われること
- statement pool など、prepared statement やキャッシュ周りの挙動が今回の変更で壊れていないこと
postgresql_adapter_test.rb の変更行数が多いことから、PostgreSQL 向けのパスについてかなり細かくケースを足していると推測できます。
- 影響範囲・注意点
3-1. アプリケーションから見える挙動の変化
例外クラスが変わる可能性があります。
これまで:
FATALに相当する PostgreSQL エラーが起きても:ActiveRecord::StatementInvalidやそのサブクラスとして扱われていた- アプリケーション側が
StatementInvalidを rescue してリトライ等していた
今後:
- 同じ状況で
ActiveRecord::ConnectionFailed(もしくは関連する接続エラー系) が発生するようになります。 rescue/ エラーハンドリングで「SQL 文法エラー等」と「接続切断」を区別しやすくなりますが、既存コードがStatementInvalidのみを想定している場合には挙動が変わる可能性があります。
対策例:
begin
Model.connection.execute("...")
rescue ActiveRecord::ConnectionFailed => e
# 接続切断: コネクション再確立やリトライ戦略を検討
rescue ActiveRecord::StatementInvalid => e
# SQL 文法ミスや constraint violation など
endConnectionFailed を上位に rescue することで、より正しい分類ができます。
3-2. コネクションプール / 再接続の挙動
FATAL で落ちたコネクションが:
- プール内に「生きているように見える」状態で残る
- 次のクエリで初めて「connection is closed」的なエラーになる
といったパターンが減り、その場で接続エラーとして扱われます。
その結果:
- トラフィックが高い環境では、「たまたま次のリクエストで初めて気付く」タイムラグが減る
- 一方で、エラーが発生したリクエストの時点で即座に
ConnectionFailedが飛ぶため、「前は通っていたのに今回は接続エラーになった」という違いが観測されるかもしれません。
3-3. PostgreSQL / libpq バージョン依存の可能性
FATAL/PANICの severity の扱い、notice receiver への振り分けなどは、PostgreSQL サーバ / libpq のバージョンや設定によって微妙に違う場合があります。- この PR はそのばらつきをある程度吸収しようとしているものの、古い組み合わせなどではテストでカバーされていない挙動があり得ます。
大規模システムや独自のコネクション設定をしている場合は、ステージング環境で:
- 意図的に DB を落とす
pg_terminate_backendでセッションを kill する- ネットワーク切断をシミュレートする
などして、「どういう例外が上がるか」「再接続戦略に問題がないか」を確認しておくと安全です。
- 参考情報 (あれば)
- PostgreSQL のエラーレベル (
ERROR,FATAL,PANICなど) 仕様:
https://www.postgresql.org/docs/current/protocol-error-fields.html
特にPG_DIAG_SEVERITY/PG_DIAG_SEVERITY_NONLOCALIZEDの説明 - libpq の非同期クエリインターフェイス (
PQsendQueryParams,PQgetResult, notice receiver):
https://www.postgresql.org/docs/current/libpq-async.html
https://www.postgresql.org/docs/current/libpq-notice-processing.html - Rails の例外クラス体系 (ActiveRecord):
ActiveRecord::ConnectionFailed,ActiveRecord::StatementInvalidなど
https://api.rubyonrails.org/classes/ActiveRecord/Errors.html (バージョンに応じた API ドキュメント参照)
この PR により、「クエリ失敗」と「接続自体が死んだ」の区別が明確になったので、アプリケーション側でも例外ハンドリングを整理しておくと、運用時のトラブルシュートがかなり楽になります。
#57656 Support composite primary keys in excluding
マージ日: 2026/6/12 | 作成者: @55728
- 概要 (1-2文で)
ActiveRecord::Relation#excluding(およびエイリアスのwithout)が複合主キーを持つモデルでエラーになっていた問題を修正し、単一主キーと同様に正常動作するようにした PR です。単一主キー向けの挙動は変えず、複合主キーのときだけ where 句の組み立て方を切り替えています。
- 変更内容の詳細
何が問題だったか
excluding は以下のように「指定したレコード(や relation)を結果から除外する」ためのメソッドです。
Post.excluding(post)
Post.excluding(posts_relation)単一主キーのモデルでは問題なく動きますが、複合主キーの場合に ActiveRecord::StatementInvalid が発生していました。
# 単一主キー: OK
Post.excluding(post)
# 複合主キー: NG
Cpk::Book.excluding(book)
# => ActiveRecord::StatementInvalid: no such column: cpk_books.["author_id", "id"]原因は、excluding! が内部で predicate_builder[primary_key, records] を使って除外条件を組み立てている点にあります。
- 単一主キー:
primary_keyは"id"などの文字列 → 正常 - 複合主キー:
primary_keyは["author_id", "id"]のような配列
→ これがそのまま 1 つのカラム名として扱われてしまい、
→ SQL 上では"cpk_books"."[""author_id"", ""id""]"という存在しないカラム参照になりエラー
どう直したか
修正方針は「複合主キーのときだけ、finder と同じ書き方で where 句を組み立てて、それを反転する」というものです。
Rails の finder はすでに複合主キーに対応しており、例えば:
Cpk::Book.where(primary_key => ids)
# primary_key: ["author_id", "id"]
# ids: [[author_id1, id1], [author_id2, id2], ...]という形で複合キーを扱えています。これと同じアプローチで排除条件を作り、その NOT を取るようにしています。
疑似コード的には以下のような分岐になります(※実際のコードは byte-to-byte では異なりますがニュアンスとして):
if Array(primary_key).size == 1
# 既存ロジック (単一主キー) は一切変更なし
predicate = predicate_builder[primary_key, records]
else
# 新ロジック (複合主キー)
# ids は excluding(record) の場合は record の複合キーの値
# excluding(relation) の場合は relation.ids から得た複合キーの配列
ids = relation.ids
clause = build_where_clause(primary_key => ids) # where(primary_key => ids) と同様の処理
predicate = clause.ast.not # それを否定して「除外」条件に
end
@records = where(predicate)ポイント:
- 単一主キーの処理ルートは一切変更していない(バイトレベルで同一)ことが PR で明示されています。
excluding(record)とexcluding(relation)の両方のパターンをテストでカバーしています。- テストは
activerecord/test/cases/excluding_test.rbに複合主キー用のケースを追加 (+21/-1)。
- 影響範囲・注意点
- 影響範囲:
ActiveRecord::Relation#excludingと#withoutを、複合主キーを持つ ActiveRecord モデルで利用しているコード。- 単一主キーのモデルは挙動が変わらないため影響なし。
- 実務的な影響:
- これまで複合主キーのモデルに対して
excluding/withoutを呼ぶと SQL エラーになっていた箇所が、正しく「そのレコード(群)を除外する」動作をするようになります。 excluding(relation)もサポートされているため、サブクエリで取得した複合主キーの集合をまとめて除外するパターンも安全に利用できます。
- これまで複合主キーのモデルに対して
- 注意点:
- 既にエラー回避のためにアプリ側で
where.notや生 SQL で独自に除外処理を書いていた場合、今後はexcluding/withoutを利用する方が一貫したインターフェイスになります。 - ただしこの PR はあくまで「正しい挙動に修正」するものなので、既存のワークアラウンドを削るかどうかは各プロジェクトで判断が必要です。
- 既にエラー回避のためにアプリ側で
- 参考情報 (あれば)
- 対象メソッド:
ActiveRecord::Relation#excludingActiveRecord::Relation#without(excludingのエイリアス)
- 関連する内部 API:
Relation#build_where_clauseRelation#ids(複合主キー時には複合キー値の配列を返す)
- 複合主キーでの finder の使い方(参考イディオム):
# 複合主キー: [:author_id, :id]
# 特定の複合キーを持つレコードを検索
Cpk::Book.find([author_id, id])
# 複数の複合キーの集合で where
Cpk::Book.where([:author_id, :id] => [[1, 10], [1, 11]])この PR は excluding も上記のような finder パターンに揃えていると言えます。
#57670 Fix Hash.from_xml raising Date::Error on type="date" values surrounded by whitespace
マージ日: 2026/6/12 | 作成者: @55728
- 概要 (1-2文で)
Hash.from_xmlがtype="date"の要素に前後の空白文字(改行やスペース)が含まれているとDate::Errorで落ちていた問題を修正した PR です。日付文字列をDate.strptimeに渡す前にto_s.stripすることで、インデント付き・整形済み XML でも問題なくパースできるようになりました。
- 変更内容の詳細
問題の挙動
Rails の ActiveSupport::XMLMini は Hash.from_xml で XML を Hash に変換するとき、type="date" が付いたノードを Date に変換します。
従来の実装:
::Date.strptime(date, "%Y-%m-%d")このとき、XML がインデント・整形されていると、実際のノード内容は以下のように前後に改行やスペースが入った文字列になります。
<event>
<starts-on type="date">
2020-01-01
</starts-on>
</event>Ruby 上では例として:
value = "\n 2020-01-01\n"
Date.strptime(value, "%Y-%m-%d") # => Date::Error: invalid dateDate.strptime は周囲の空白を自動では無視しないため、Hash.from_xml 全体が Date::Error で失敗していました。
require "active_support/core_ext/hash/conversions"
Hash.from_xml(%(<event><starts-on type="date">\n 2020-01-01\n</starts-on></event>))
# これが Date::Error で落ちる一方、type="integer" など他の型は to_i などを通すため、同じような空白があっても問題なくパースできており、型ごとの挙動が不整合でした。
修正内容
activesupport/lib/active_support/xml_mini.rb 内の日付パース部分を 1 行変更:
Before:
"date" => lambda { |date| ::Date.strptime(date, "%Y-%m-%d") },After:
"date" => lambda { |date| ::Date.strptime(date.to_s.strip, "%Y-%m-%d") },ポイント:
to_sで非文字列入力(nil や数字など)もとりあえず文字列化stripで前後の空白(スペース・タブ・改行)を除去boolean型の実装がすでにto_s.stripを使っており、それと揃えた形
これにより、次のような XML でも期待通り Date オブジェクトになります:
xml = <<~XML
<event>
<starts-on type="date">
2020-01-01
</starts-on>
</event>
XML
Hash.from_xml(xml)
# => { "event" => { "starts_on" => Date.new(2020, 1, 1) } }テストの追加・修正
activesupport/test/core_ext/hash_ext_test.rbにテスト追加:
def test_date_type_from_xml_with_surrounding_whitespace
xml = <<~XML
<event>
<starts-on type="date">
2020-01-01
</starts-on>
</event>
XML
hash = Hash.from_xml(xml)
assert_equal({ "starts_on" => Date.new(2020, 1, 1) }, hash["event"])
end- 修正前はこのテストが
Date::Errorで失敗 - 修正後は期待どおり
Dateオブジェクトに変換されて成功
activesupport/test/xml_mini_test.rbの既存テストを微修正:
非文字列入力に対する挙動の例外クラスを TypeError から Date::Error に変更しています。
理由: date.to_s.strip を通してから Date.strptime されるため、パース不能時の例外が Date::Error で統一されるようになったためです。
- 影響範囲・注意点
影響を受ける主なケース
Hash.from_xmlを利用し、XML 内でtype="date"を使っているアプリケーション- 特に、整形済み・インデント付き XML(人間が読みやすいように改行・スペースが入っているもの)を入力としている場合
期待されるポジティブな影響
- これまで
Date::Errorで落ちていた XML が正常にパースされるようになる integerやbooleanなど、他の型との挙動が「前後の空白を許容する」という点で一貫する
- これまで
互換性・注意点
date型は、渡された値に対して必ずto_s.stripを行った結果でDate.strptimeするように変わったため、以下のようなケースでは例外の種類やタイミングが変わる可能性があります。- 非文字列(例: オブジェクト、数値、nil など)を
type="date"に対して渡していた場合- 以前: 型エラー (
TypeError) を期待していたコードがあれば、Date::Errorに変わる
- 以前: 型エラー (
- ただし、通常の XML パース用途では非文字列が入ることはほぼないため、一般的な利用者への影響は限定的です。
- 非文字列(例: オブジェクト、数値、nil など)を
パフォーマンス
- 1 ノードあたり
to_s.stripが増えるだけのごく軽微な変更であり、現実的な XML サイズではパフォーマンス影響は無視できるレベルと考えられます。
- 1 ノードあたり
- 参考情報 (あれば)
- 類似実装:
boolean型のパースはすでにto_s.stripを行っており、本 PR はそれに合わせてdate型の扱いを揃えた変更です。
- コードの読みどころ:
ActiveSupport::XMLMini::PARSINGのマップ定義部分Hash.from_xml周辺の処理フロー(XMLMiniを通して型変換が行われる流れ)
#57671 Fix MemoryStore#cleanup raising NoMethodError with a non-DupCoder serializer
マージ日: 2026/6/12 | 作成者: @55728
- 概要 (1-2文で)
ActiveSupport::Cache::MemoryStoreを非デフォルトのシリアライザ(serializer: :marshal_7_1など)付きで使った際に、cleanup実行時にNoMethodErrorが発生して書き込みが失敗する問題を修正した PR です。cleanup内でも他と同様にdeserialize_entryを通してエントリを扱うようにし、テストを追加しています。
- 変更内容の詳細
問題の内容
MemoryStore は内部的に @data にキャッシュを保持していますが、構成によって @data の中身が変わります。
デフォルト (
DupCoder) 利用時@data[key]には 生のActiveSupport::Cache::Entryオブジェクト が入る- そのため
entry = @data[key]; entry.expired?のような呼び出しがそのまま動く
非デフォルトのシリアライザ / コーダ利用時
例:serializer: :marshal_7_1serializer: :message_pack- カスタム
coder: ... - この場合
@data[key]には シリアライズ済みのStringが入る - よって
String#expired?は存在せず、cleanup中にNoMethodErrorが発生する
さらに厄介なのは、cleanup は単体で呼ばれるだけでなく、容量制限 (size オプション) を越えたときに write_entry -> prune -> cleanup の流れで自動的に呼ばれることです。そのため容量上限到達後の あらゆる write が例外で落ちる 状態になり、期限切れエントリも一切掃除されなくなっていました。
修正内容
対象箇所は activesupport/lib/active_support/cache/memory_store.rb の cleanup メソッドです。
従来:
@data.each_key do |key|
entry = @data[key]
delete_entry(key) if entry && entry.expired?
end修正後:
@data.each_key do |key|
entry = deserialize_entry(@data[key])
delete_entry(key) if entry && entry.expired?
endポイント:
read_entryでは元々deserialize_entryを通してEntryを復元しており、cleanupだけが「生の@data」に直接触っていました。- 今回
cleanupでもdeserialize_entryを通すことで、- デフォルトの
DupCoderでも - 非デフォルトのシリアライザ / コーダ利用時でも
一貫して「Entryオブジェクトに対してexpired?する」ようになりました。
- デフォルトの
deserialize_entryは@data[key]が存在しない場合にはnilを返すため、既存のif entry && entry.expired?のロジックはそのまま成立します。
追加されたテスト
activesupport/test/cache/stores/memory_store_test.rb にテストが追加されています:
def test_cleanup_with_non_dup_coder_serializer
store = ActiveSupport::Cache::MemoryStore.new(
serializer: :marshal_7_1,
size: 200,
)
store.write("a", "x" * 100, expires_in: 0.01)
store.write("b", "y" * 100)
sleep 0.05
# ここで cleanup が呼ばれても例外にならないこと
assert_nothing_raised do
store.cleanup
end
# 有効期限切れの "a" は削除されている
assert_nil store.read("a")
# 期限切れでない "b" は残っている
assert_equal "y" * 100, store.read("b")
end※ 実際のテストコードは若干異なる可能性がありますが、意図としては:
serializer: :marshal_7_1を使ったMemoryStoreを作る- すぐ期限切れになるエントリと、有効なエントリを書き込む
- 期限が切れるまで
sleepした後cleanupを呼ぶ - 例外が発生しないこと、および
- 期限切れエントリは削除され
- 有効なエントリは残る
ことを検証しています。
- 影響範囲・注意点
影響を受けるのは以下のようなケースです:
ActiveSupport::Cache::MemoryStoreを利用しておりserializer: :marshal_7_1、serializer: :message_pack、カスタムcoderなど DupCoder 以外のシリアライザ / コーダ を指定していて- かつ
sizeオプションでメモリ上限を設定し、その上限を越えるような書き込みを行うケース
この PR 適用前:
- 上限到達後に
writeがprune -> cleanupを呼び出すたびにNoMethodErrorが発生 - 以降の書き込みが実質的に失敗し続ける
- 有効期限切れエントリも一切掃除されない
- 上限到達後に
この PR 適用後:
- 非デフォルトシリアライザ使用時でも
cleanupが正常動作し、期限切れエントリが適切に削除される writeが例外で止まらなくなる
- 非デフォルトシリアライザ使用時でも
パフォーマンス面:
cleanup実行時にdeserialize_entryを通す分、シリアライズ/デシリアライズのコストがかかりますが、read_entryでも同様に行っている処理であり、cleanup自体も通常は頻繁には呼ばれないため、挙動としては妥当なトレードオフと考えられます。
互換性:
- 既存の API やオプションに変更はなく、挙動は「元々期待されていた動作」に近づくだけなので、後方互換性の観点でも問題は少ないと思われます。
- 参考情報 (あれば)
- 対象クラス:
ActiveSupport::Cache::MemoryStore- Rails ガイド(英語): “Active Support Instrumentation and Caching” のメモリストア節
- 類似実装:
read_entryがすでにdeserialize_entryを通している点から、今回の変更はその挙動に揃える形の修正となっています。
- 関連オプション:
serializer:(:marshal_7_1,:message_packなど)coder:(カスタムコーダ指定)size:(メモリストアの最大サイズ)
#57672 Fix Time#advance and DateTime#advance mutating the caller's options hash
マージ日: 2026/6/12 | 作成者: @55728
- 概要 (1-2文で)
Time#advanceとDateTime#advanceが、呼び出し元から渡された options ハッシュを破壊的に変更していた問題を修正した PR です。ハッシュを複製してから処理するようにすることで、呼び出し元のハッシュの汚染やFrozenErrorを解消し、Date#advanceとの挙動の一貫性も取っています。
- 変更内容の詳細
問題点
Time#advance / DateTime#advance は、以下のように渡された options ハッシュに対してキーを書き換えるロジックを持っています。
:weeksを:daysに畳み込む (例:weeks: 1→days: days + 7):daysを:hoursに畳み込む (例:days: 1→hours: hours + 24)- その過程で
hours: 0などのキー追加も行う
従来はこれを「受け取ったハッシュそのもの」に対して行っていたため、以下のような問題がありました。
require "active_support/all"
options = { weeks: 1, days: 2 }
Time.new(2024, 1, 1).advance(options)
p options
# 実際: { weeks: 1, days: 2, hours: 0 } # ← 呼び出し元のハッシュが書き換えられている
# 期待: { weeks: 1, days: 2 }さらに、options を freeze して渡すと、内部でのキー再代入により FrozenError が発生していました。
Time.new(2024, 1, 1).advance({ weeks: 1, days: 2 }.freeze)
# 実際: FrozenError: can't modify frozen Hash
# 期待: 正常に計算された Time を返す同様の問題は DateTime#advance にも存在していましたが、Date#advance は options を参照するだけで書き換えないため、3 つのメソッド間で挙動が不一致でした。
修正内容
Time#advance および DateTime#advance のメソッド先頭に、以下の 1 行を追加しています。
options = options.dupこれにより:
- 以降の
options[:days] = ...やoptions[:hours] ||= 0といった操作は、呼び出し元とは別のハッシュに対して行われる - 呼び出し元の options ハッシュには一切変更が加わらない
- たとえ呼び出し元が
options.freezeしていても、dupされた新しいハッシュはミュータブルなのでFrozenErrorは発生しない - 既存の計算ロジック (何週間分を何日分に換算など) はそのままのため、返る
Time/DateTimeの値は変わらない
実質的には、「破壊的に見える内部実装を呼び出し元から隔離した」形です。
テスト
次の 2 パターンについて、Time / DateTime それぞれで回帰テストが追加されています。
options ハッシュが呼び出し後も変化していないこと
rubyoptions = { weeks: 1, days: 2 } Time.new(2024, 1, 1).advance(options) assert_equal({ weeks: 1, days: 2 }, options)凍結された options ハッシュでも例外が出ないこと
rubyoptions = { weeks: 1, days: 2 }.freeze assert_nothing_raised do Time.new(2024, 1, 1).advance(options) end
time_ext_test.rb, date_time_ext_test.rb, time_with_zone_test.rb など既存テストもすべてグリーンで、他の挙動に影響が出ていないことが確認されています。
- 影響範囲・注意点
- 影響を受けるのは以下を利用しているコードです:
Time#advance(options_hash)DateTime#advance(options_hash)
Date#advanceの挙動はもともと非破壊的であり、この PR の影響はありません。
開発者視点でのポイント:
呼び出し元ハッシュの “副作用” に依存していたコードがあれば壊れる可能性
- 例えば、こんなコードは以前は動いていたかもしれません:ruby
opts = { days: 1 } now = Time.current now.advance(opts) # 以前: opts に hours: 24 が入ることを前提にしていた (悪い例) do_something_with(opts[:hours]) - このような「advance の副作用として options が書き換わること」を前提としたコードは、今回の変更後は動かなくなります。
- ただし、こうした依存は意図された使い方ではなく、バグに近い設計といえます。
- 例えば、こんなコードは以前は動いていたかもしれません:
options を複数回再利用・共有・freeze している場合は恩恵を受ける
- 典型的なユースケース:ruby
ADVANCE_OPTS = { weeks: 1, days: 2 }.freeze 10.times do Time.current.advance(ADVANCE_OPTS) # 以前は 1 回目で FrozenError end - 今後はこのような「定数オプションを使い回す」スタイルが問題なく動作します。
- 典型的なユースケース:
パフォーマンス面の影響は極小
Hash#dup一回分のコストが増えますが、advance自体が日時の計算処理を行うメソッドであり、通常のアプリケーションではボトルネックにはなりにくい変更です。- ただし、超高頻度・大量ループで
Time#advanceを叩くようなコードがある場合、プロファイル上で微小な差が出る可能性はあります。
TimeWithZone#advanceのような関連メソッドtime_with_zone_test.rbもグリーンであることから、ActiveSupport::TimeWithZone周りを含めた既存挙動への影響はないと判断されています。TimeWithZone#advanceは内部でTime#advanceなどを利用しているため、副作用が減った分、思わぬハッシュ汚染バグのリスクが下がります。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57672
- 関連 API ドキュメント (概念確認用):
Time#advance/DateTime#advance/Date#advance(ActiveSupport 拡張)
- 「引数として受け取った Hash/Array を破壊的に変更しない」ことは Ruby/Rails では一般的な慣習であり、この PR はその慣習に沿った修正になっています。
#57686 Re-raise suppressed RedisClient errors in all RedisCacheStoreTests classes
マージ日: 2026/6/12 | 作成者: @yahonda
- 概要 (1-2文で)
Redis を使ったキャッシュストアのテストにおいて、これまで握りつぶされていたRedisClientのエラーをすべてのRedisCacheStoreTests系テストで再スローするようにし、原因が分かりにくい失敗を診断しやすくした PR です。前回 PR (#57571) で一部クラスにだけ入っていたerror_handlerの設定を、共通のデフォルトに移して全テストクラスに適用しています。
- 変更内容の詳細
背景となる問題
ActiveSupport::Cache::RedisCacheStoreTests::FailureRaisingFromMaxClientsReachedErrorTest#test_fetch_with_block_read_failure_raises が稀に失敗するが、そのときの失敗メッセージが
--- expected
+++ actual
@@ -1,3 +1 @@
-# encoding: US-ASCII
-# valid: true
-"ix70bwHXFsZAhUKh"
+nilのように「期待していた文字列 vs 実際は nil」しか出ず、なぜ nil になったのか(Redis 接続エラーなど)が分からない、という問題がありました。
原因としては、Redis 側でのタイムアウトや接続エラー (RedisClient::CannotConnectError など) が、テスト中に設定されている error_handler によって握りつぶされ、テストの失敗からは見えなくなっていたためです。
今回の修正内容
前回の PR (#57571) では、RedisCacheStoreCommonBehaviorTest に対して error_handler を「エラーを再スローする」ものに変えましたが、他の RedisCacheStoreTests クラスにはまだ適用されていませんでした。
この PR では、その error_handler 設定をテストストア共通の初期化処理(StoreTest#lookup_store のデフォルトオプション)に移動し、全ての RedisCacheStoreTests ベースのテストで同じ挙動になるようにしています。
コード上は activesupport/test/cache/stores/redis_cache_store_test.rb の中で
- 個別クラスに書かれていた
error_handlerの設定を削除(-5 行) - 共通の場所に 1 行だけ
error_handlerを設定する(+1 行)
という最小限の変更です。
これにより、同じようなタイムアウトが発生すると、今後は以下のように Redis の接続エラーがそのままテストのエラーメッセージに出るようになります:
Error:
ActiveSupport::Cache::RedisCacheStoreTests::FailureRaisingFromMaxClientsReachedErrorTest#test_fetch_with_block_read_failure_raises:
RedisClient::CannotConnectError: user specified timeout for redis:6379 (redis://redis:6379/1)
...
lib/active_support/cache.rb:678:in 'ActiveSupport::Cache::Store#write'
test/cache/behaviors/failure_raising_behavior.rb:18:in 'FailureRaisingBehavior#test_fetch_with_block_read_failure_raises'これで、「nil になった」ではなく「Redis への接続に失敗した」ことが明示的に分かるようになります。
- 影響範囲・注意点
- 対象は テストコードのみ であり、
ActiveSupport::Cache::RedisCacheStoreの本番挙動には影響しません。 - すべての
RedisCacheStoreTests系クラスでerror_handlerが「エラーを再スローする」よう統一されるため、これまで黙殺されていた Redis 接続エラー等がテスト失敗として表面化しやすくなります。- 結果として、テストスイートが不安定な環境(遅い CI や不安定な Redis サーバ)では、以前よりも Redis 接続関連のエラーが露出する可能性があります。
- ただしこれは「実際に起きているエラーが見えるようになる」という性質のものであり、根本的なバグを隠さないという意味で望ましい変更です。
- 変更ファイルは 1 つ・差分も小さく、
error_handlerの適用範囲を広げただけなので、テストの構造や API 互換性への影響はありません。
- 参考情報 (あれば)
- この PR は #57571 のフォローアップであり、
RedisCacheStoreCommonBehaviorTestだけに入っていた「エラー再スローerror_handler」を他の RedisCacheStore テストにも反映する目的です。 - 失敗ログ(参考):
https://buildkite.com/rails/rails-nightly/builds/4426#019eb87c-96a1-49cf-a581-6e8ca348864f/L8294
ここで原因が分からないnilとの比較になっていた問題を解消する意図があります。
#57679 Fix increment! with explicit query constraints
マージ日: 2026/6/12 | 作成者: @jsaubry
- 概要 (1-2文で)
increment!/decrement!がquery_constraintsを持つモデルに対しても、更新時のWHERE句にそれらの制約列を正しく含めるように修正された PR です。単一主キー + 明示的query_constraints環境で、主キーのみで UPDATE してしまう不具合を解消します。
- 変更内容の詳細
何が問題だったか
increment! / decrement! は内部的に update_counters を使ってカラムをインクリメント/デクリメントしていますが、その際、主キーだけを条件に UPDATE していました。
元コード(簡略化):
# ActiveRecord::Persistence#increment!
self.class.update_counters(id, attribute => change, touch: touch)update_counters 側:
# ActiveRecord::CounterCache.update_counters
unscoped.where!(primary_key => id).update_counters(counters)ここで where!(primary_key => id) としているため、主キー以外の query_constraints が無視されるという問題がありました。
- 複合主キーの場合:
idに複合キー情報が入っているため問題にならない - 問題になるケース: 単一カラム主キーのモデルで、
query_constraintsが主キーと一致しない場合
例:
class TopicWithConstraints < Topic
query_constraints :author_name, :id
end
topic.increment!(:replies_count)
# Before: UPDATE topics SET replies_count = replies_count + 1 WHERE id = ?
# After: UPDATE topics SET replies_count = replies_count + 1 WHERE author_name = ? AND id = ?このように、本来 author_name も WHERE 条件に含めるべきところが、id のみで更新されていました。
どう直したか
increment! が「id を渡して update_counters を呼ぶ」のをやめ、自分自身の query_constraints を用いて更新用の Relation を組み立てるように変更しています。
修正後のイメージ:
# ActiveRecord::Persistence#increment!
counters = { attribute => change }
relation = self.class.unscoped.where!(_query_constraints_hash)
relation.update_counters(counters)ポイント:
_query_constraints_hashは、そのレコードを一意に特定するためのハッシュを返します- 明示的な
query_constraintsがある場合: そこに列挙したカラムが含まれる - ない場合: 主キーにフォールバックする
- 明示的な
- これにより、
- 通常の主キー更新 → これまで通り主キーで絞り込む
- 複合主キー → 既存どおり問題なし(ID に複合キーが含まれている)
- 明示的
query_constraintsあり → すべての制約カラムを WHERE 句に含める
decrement! は内部実装として increment! に負の値を渡しているだけなので、同じ修正の恩恵を受けます。
テスト・ドキュメント
activerecord/test/cases/persistence_test.rbにテストが追加され、query_constraintsを持つモデルでincrement!が適切な WHERE 条件を生成することを確認activerecord/CHANGELOG.mdにこの挙動修正が追記
- 影響範囲・注意点
影響を受けるのは以下のようなケースです:
- Active Record モデルで
query_constraintsを明示的に設定している - そのモデルに対して
increment!/decrement!を使っている - これまで主キーだけで UPDATE されていたが、本来は追加の制約カラムも含めるべきだった
- Active Record モデルで
期待される挙動の変化:
- これまで:
increment!/decrement!が主キーだけで UPDATE を行っていたため、
例えば「論理シャーディング」「マルチテナント」「パーティションキー」的なカラムをquery_constraintsにしている環境では、誤ったレコードが更新されうる
- これから:
query_constraintsに列挙したカラムも WHERE 句に含めて UPDATE されるため、
レコードの特定がより安全かつ一貫したものになる
- これまで:
既存コードへの互換性:
- 「主キーだけで十分に一意に特定できる」という前提で
query_constraintsを増やしていなかったケースは、挙動に変更なし - もし「意図的に主キーだけで更新したいが、
query_constraintsには別用途のカラムを入れている」といった特殊な設計をしていた場合は、挙動が変わります
(ただしそのような設計はquery_constraintsの用途から外れていると言えます)
- 「主キーだけで十分に一意に特定できる」という前提で
パフォーマンス面:
- WHERE 句に制約カラムが増えるため、インデックス設計によってはクエリプランが変わる可能性があります
- とはいえ
query_constraintsはもともとレコード特定のために使う前提なので、通常はインデックスも張られているはずで、実運用上は改善または許容範囲の変化となることが多いと考えられます
- 参考情報 (あれば)
対象メソッド:
ActiveRecord::Persistence#increment!ActiveRecord::Persistence#decrement!(内部的にはincrement!利用)
関連箇所:
ActiveRecord::Persistence#_query_constraints_hashActiveRecord::CounterCache.update_counters
用例イメージ(マルチテナントなど):
rubyclass AccountRecord < ApplicationRecord query_constraints :tenant_id, :id end record.increment!(:usage_count) # 修正前: WHERE id = ? # 修正後: WHERE tenant_id = ? AND id = ?
この PR により、「query_constraints を設定しているなら、increment! / decrement! もそれを尊重する」という、一貫した挙動になります。
#57682 Ractor safe readonly attributes
マージ日: 2026/6/12 | 作成者: @andrewn617
- 概要 (1-2文で)
Rails を Ractor セーフにする取り組みの一環として、ActiveRecord::Base._attr_readonlyで使われる配列を「最初から凍結(frozen)された不変オブジェクト」として扱うようにし、テスト用の Ractor 関連アサーションも整理・拡張した PR です。これにより、クラスレベルの設定情報が Ractor 間で安全に共有しやすくなります。
- 変更内容の詳細
2-1. ActiveRecord::Base._attr_readonly の Ractor セーフ化
対象ファイル:
activerecord/lib/active_record/readonly_attributes.rb
_attr_readonly は「読み取り専用属性」を保持する内部用メソッドで、クラスレベルに配列として保存されています。この PR では、その配列を以下の方針で扱うようにしています:
- デフォルトの配列を最初から
freezeしておく - 要素を追加するたびに「元の配列を変更せず」
dupしてから変更し、変更後の配列を再度freezeする
疑似コードで書くと、以下のようなパターンになります:
# もとのイメージ(破壊的に変更しがち)
@_attr_readonly ||= []
@_attr_readonly << :title # ← ミューテーションが発生し、Ractor セーフでない
# この PR でのパターン(イミュータブル配列として扱う)
@_attr_readonly ||= [].freeze
@_attr_readonly = (@_attr_readonly + [:title]).freeze
# または dup して push して freeze など、いずれにせよ:
# - 元の配列は変更しない
# - 新しい配列を凍結して入れ直すこれにより、クラス属性として保持される _attr_readonly が、常に「凍結された配列」として振る舞い、Ractor 間でそのまま shareable なオブジェクトとして扱いやすくなります。
※PR 説明文では、例外ラッパー設定(exception wrapper configs)で既に導入済みの手法(#57483)と同様のパターンを採用していると述べられています。
2-2. Ractor 用アサーションの整理・強化
対象ファイル:
activesupport/lib/active_support/testing/ractors_assertions.rbactivesupport/test/backtrace_cleaner_test.rbrailties/test/backtrace_cleaner_test.rbactiverecord/test/cases/base_test.rb
Ractor セーフティをテストするためのアサーションを 2 種類に整理しています:
assert_ractor_shareable- 「何もしなくても Ractor 共有可能であるべき」オブジェクトに対して使う
- 例:
ActiveRecord::Base._attr_readonlyのように、常に frozen な配列にしておきたいクラス属性 - テストでは「アプリケーションを凍結する処理などを通さずに、そのまま
Ractor.shareable?が true になること」を確認する役割
assert_ractor_make_shareable- 「アプリケーションの初期化/凍結のタイミングで
Ractor.make_shareableによって共有可能にする想定」のオブジェクトに対して使う - 例: backtrace cleaner のような、最終的には凍結して shareable にするが、初期状態ではミュータブルなことがあるオブジェクト
- 「アプリケーションの初期化/凍結のタイミングで
今回の PR では:
- 以前の PR (#57574) で入っていたアサーションを見直し、
_attr_readonlyのような「最初から shareable であるべきもの」にassert_ractor_shareableを使うように修正- backtrace cleaner 関連のテストには
assert_ractor_make_shareableを使うように修正
- ActiveRecord のテスト (
activerecord/test/cases/base_test.rb) に_attr_readonlyが Ractor shareable であることを検証するテストを追加
することで、「どのオブジェクトがいつ shareable であるべきか」をテストレベルで明確に区別できるようにしています。
- 影響範囲・注意点
既存アプリの挙動への互換性
_attr_readonly自体は「属性名の一覧」を持つだけの配列であり、通常の利用ではアプリ側から直接ミューテート(<<など)するケースは少ない前提です。- ただし、もしアプリやプラグインが
ActiveRecord::Base._attr_readonly << :fooのように「内部配列への直接破壊的変更」に依存している場合は、FrozenErrorが発生する可能性があります。 - 推奨パターンとしては、
attr_readonlyDSL やクラスメソッドを通じて設定する形に留めるべきで、内部配列を直接いじらない方が安全です。
Ractor を使わないアプリへの影響
- Ractor を使わない場合も、配列がイミュータブルになる点以外の挙動差はほぼありません。
- 読み取り専用属性の解決やクエリ挙動に変更はなく、パフォーマンスへの影響もごく小さい(配列の dup + freeze)範囲に限定されます。
今後のパターンの標準化
- クラスレベルに設定情報(配列・ハッシュ)を持つ場合、「デフォルトを frozen にする」「更新時は dup してから freeze し直す」というパターンが今後の Rails コアの標準的な実装スタイルになっていくことを示唆しています。
- エンジンや gem 側で Ractor 対応を進める際にも、このパターンを参考にするとよいです。
- 参考情報 (あれば)
この PR で参照されている関連 PR:
- 例外ラッパー設定を Ractor セーフにした PR:
https://github.com/rails/rails/pull/57483 - Ractor 用アサーション導入・改善の前段となる PR:
https://github.com/rails/rails/pull/57574
- 例外ラッパー設定を Ractor セーフにした PR:
Ractor 自体の仕様:
- Ruby 公式ドキュメント (英語):
https://docs.ruby-lang.org/en/master/Ractor.html - ポイントとして、
Ractor.shareable?が true になるのは、主に「完全に凍結されたオブジェクトグラフ」であることが条件となるため、本 PR のように「クラス設定をミュータブルからイミュータブルへ寄せていく」変更が必要になります。
- Ruby 公式ドキュメント (英語):
#57674 Fix String#truncate with :separator and an over-long :omission
マージ日: 2026/6/12 | 作成者: @55728
- 概要 (1-2文で)
String#truncateに:separatorオプションを指定しつつ、:omissionの長さがtruncate_to以上になるケースで、想定より長い文字列が返ってしまう不具合を修正した PR です。separatorを使う場合も、非separatorパスと同様に「省略記号だけ」を返すよう挙動を揃えています。
- 変更内容の詳細
問題の挙動
対象メソッドは ActiveSupport の String#truncate です。以下のようなコードで問題が発生していました。
require "active_support/core_ext/string/filters"
"Hello World foo bar baz".truncate(2, separator: " ")
# 実際: "Hello World foo bar..."
# 期待: "..."
"Hello World foo bar baz".truncate(2)
# => "..." # separator なしだと正しい内部では概ね以下のような処理になっています(簡略化):
length_with_room_for_omission = truncate_to - omission.length
# 例: truncate_to=2, omission="..."(length=3) の場合
# length_with_room_for_omission = 2 - 3 = -1- 通常(separator なし)の分岐では、この
length_with_room_for_omissionが負の場合は、元文字列の前半を一切付けず、omissionだけを返すようになっていました。 - 一方で
separator指定ありの分岐では、この負の値をそのままrindexの開始位置に渡していました。
# 問題のあったイメージ
stop = rindex(separator, length_with_room_for_omission) || length_with_room_for_omission
self[0, stop] + omissionRuby の String#rindex は、第2引数が負のとき「末尾からのオフセット」として解釈します。
そのため:
length_with_room_for_omissionが負でもrindexが末尾近くでマッチを見つけてしまうstopが末尾付近の大きな値になり、self[0, stop]で「ほぼ全文」が取れてしまう- 結果として
"Hello World foo bar..."のように、想定より長い文字列が返る
というバグになっていました。
修正内容
修正は「どのときに separator を使うか」の条件を 1 行だけ変更するものです。
- 具体的には、
separatorを使う分岐に入る条件にlength_with_room_for_omission >= 0を追加 - これにより、「省略記号を付ける前に入れられる文字数が負」の場合は separator 分岐に入らないようにした
結果として:
length_with_room_for_omission >= 0
→ 元文字列の一部 +omissionを返す(従来どおり separator で区切り位置を探す)length_with_room_for_omission < 0
→self[0, length_with_room_for_omission]は""になり、"" + omissionとして「省略記号のみ」を返す
→ 非 separator パスと同じ挙動に揃う
テストの追加
次のようなテストが追加されています。
assert_equal "[...]", "Hello Big World!".truncate(
2, omission: "[...]", separator: " "
)
assert_equal "[...]", "Hello Big World!".truncate(
2, omission: "[...]", separator: /\s/
)- 修正前:
"Hello Big[...]"となりテスト失敗 - 修正後:
"[...]"となりテスト成功
これにより、「小さい truncate_to + 長すぎる :omission + separator(文字列/正規表現)でも、 omission のみが返る」ことが担保されました。
- 影響範囲・注意点
- 対象: ActiveSupport の
String#truncateをseparator:オプション付きで呼び出しているコード - 影響するケース:
truncate_to(第一引数 orlength:オプション)より:omissionの長さが長い、あるいは同じ長さ- かつ
separator:を指定している場合
- これまでは「separator ありの場合だけ、ほぼ全文 + omission」が返っていたケースが、「omission だけ」に変わります。
- 非
separatorパスの挙動に揃える変更なので、「truncateの設計として一貫性のある挙動になった」と言えますが、もし既存コードが「実際のバグ挙動」に依存していた場合は、表示内容が短くなる可能性があります。
運用上の注意:
- UI やログなどで
truncateの出力を snapshot テストや文字列比較でテストしている場合、separator付きで上記条件を満たすケースがあればテストが変わる可能性があります。 truncate_toと:omissionの長さの関係を意識していない場合でも、omissionを長くカスタマイズしていると今回の修正パスに入る可能性があります。
- 参考情報 (あれば)
- 対象メソッド:
ActiveSupport::CoreExtensions::String::Filters#truncate - 変更ファイル:
activesupport/lib/active_support/core_ext/string/filters.rb- separator 分岐条件の 1 行修正
activesupport/test/core_ext/string_ext_test.rbseparator:+ 長い:omission向けのテスト 2 ケース追加
#57681 Make some lambdas in constants shareable
マージ日: 2026/6/11 | 作成者: @andrewn617
- 概要 (1-2文で)
Rails の各所で定数として保持されている lambda を、Ruby 4.0+ の Ractor 上で安全に共有できるようにするため、「shareability shim」を使って shareable にした PRです。将来的な Ractor 対応・並列実行対応を見据えた小さな互換性対応で、挙動そのものは基本的に変わりません。
- 変更内容の詳細
背景・目的
- 「リクエスト処理を Ractor 内で行う」実験の一環として、
- 定数として定義された lambda / Proc が Ractor 間で共有可能 (shareable) である必要がある
- Ruby 3.2 以降〜4.0+ では、オブジェクトが Ractor 間でやり取りできるかどうかが厳しくチェックされるため、
- 一部の lambda をそのまま定数に置くと「共有不可能なオブジェクト」としてエラーになる可能性がある
- そこで Rails 側で用意している「shareability shim」を使い、対象の lambda を Ractor でも共有可能な形にしている。
実際の変更箇所 (概念的説明)
変更ファイルはすべて Action Pack 周辺で、以下のようなところにある「定数 lambda」の定義方法が変わっています。
actionpack/lib/action_controller/metal.rbactionpack/lib/action_dispatch/http/parameters.rbactionpack/lib/action_dispatch/journey/visitors.rbactionpack/lib/action_dispatch/routing/mapper.rbactionpack/lib/action_dispatch/routing/route_set.rb
中身のロジックは変えずに、「lambda をそのまま定数に入れる」のではなく「shareability shim 経由で wrap して定数に入れる」形に変更しています。
疑似コードでいうと、例えば:
# 変更前 (イメージ)
SOME_LAMBDA = ->(env) { ... }
# 変更後 (イメージ)
SOME_LAMBDA = ShareableLambda.wrap(->(env) { ... })のように、定数として保持している lambda/Proc をラッパー経由で登録することで、Ruby 4.0+ かつ Ractor 環境でも Ractor.shareable? に通るようにしていると考えられます。
Rails コアにはすでに「shareability shim」が存在しており、Ruby のバージョン差異を吸収しながら Ractor.make_shareable 等を使う/使わないを切り替える役割を持っています。この PR は、その shim を Action Pack 内のいくつかの lambda に適用しただけで、処理の本質的な挙動は変えていません。
- 影響範囲・注意点
影響範囲
- 主な対象は以下のレイヤーの内部実装:
- コントローラ基盤 (
ActionController::Metal) - パラメータ処理 (
ActionDispatch::Http::Parameters) - ルーティング・Journey の visitor 処理 (
ActionDispatch::Journey::Visitors) - ルーティング定義と RouteSet (
ActionDispatch::Routing::Mapper,RouteSet)
- コントローラ基盤 (
- これらは Rails のリクエスト処理~ルーティング~パラメータ解析の中核部分ですが、
- 変更は「lambda の宣言方法」(shareable 化) に限定されており、
- 呼び出し方・返り値・公開 API のシグネチャは変わっていないと考えてよいです。
アプリケーション開発者への実務的な影響
- 通常の (シングルプロセス・マルチスレッド) Rails アプリでは、挙動の変化はほぼありません。
- 将来的に:
- Rails が「Ractor を使ったリクエスト処理」を正式にサポートする場合、
- ルーティング / コントローラ / パラメータ処理が「Ractor 間で安全に共有」できる前提が整いつつある、という位置づけになります。
- 独自に Rails 内部の定数 lambda を書き換えたり monkey patch している場合:
- 似たように shareable でないオブジェクトを閉じ込めると、Ractor 環境では問題が出る可能性があります。
- Ractor 対応を視野に入れるなら、自前の lambda / Proc も「外側から状態を閉じ込めない」「shareability shim を使う」といった方針を取る必要が出てきます。
注意点
- この PR ではテスト追加はされておらず、既存テストでの挙動維持確認にとどまっているようです。
- 実際に Ractor でリクエストを捌く構成は、まだ「実験フェーズ」であり、本 PR だけで完全対応になるわけではありません。
- Ruby 4.0+ を前提としたコードパスでのみ意味を持つ変更であり、古い Ruby では shim が安全にフォールバックする想定です。
- 参考情報 (あれば)
- Ruby Ractor (並列実行モデル) の概要:
- Ractor 間でやりとりできるのは「shareable なオブジェクト」に限られる
Ractor.make_shareable(obj)によって「凍結」などを行い、共有可能な形に変換できる
- Rails 側の「shareability shim」は、Ruby のバージョン差異を隠蔽するためのヘルパーで、
- Ruby 3 系では no-op または限定的な処理
- Ruby 4.0+ では実際に
Ractor.make_shareable等を呼ぶ
という形で実装されていることが多いです。
- 今後、Ractor 対応を意識した gem やライブラリを書く場合も、
- グローバル定数に保持するオブジェクト (特に Proc / lambda / mutable な構造体) の shareability には注意が必要になります。
#57626 Introduce a new mechanism for applications to prepare for ractor safety
マージ日: 2026/6/11 | 作成者: @Edouard-chin
- 概要 (1-2文で)
このPRは、Railsアプリケーションが将来的に Ractor セーフになるための準備として、「RailsのAPIに渡される proc が Ractor 的に安全かどうか」を検査・制御する新しい仕組みを導入したものです。まずはActionDispatch::Routing::RouteSet(ルーティング)に適用されており、今後ほかの部分にも広げていく前提の基盤的変更です。
- 変更内容の詳細
背景: なぜ必要か
Ruby 3 以降の Ractor は、並列実行のための仕組みですが、Ractor 間で共有できるオブジェクトは Ractor.make_shareable で「共有可能」だと判定されたものに限られます。
Rails では、routes や各種設定にブロック(proc)を渡す場面が多く、そこに「共有不能なオブジェクト」を閉じ込めていると、Ractor 対応の際に Ractor::IsolationError が発生します。
PR の説明にある例:
Rails.application.routes.draw do
to_resolve = [:basket, anchor: "items"]
resolve("Cart") { to_resolve }
endこの場合、resolve に渡されるブロックが to_resolve というローカル変数(配列 + ハッシュ付き)をクロージャとしてキャプチャし、それが shareable ではないため Ractor.make_shareable で Ractor::IsolationError が起きうる、という問題があります。
新しい仕組み: ActiveSupport::Ractors のモード
このPRでは、Rails がアプリケーションの proc を shareable にしようと試みるかどうか、その失敗をどう扱うかを制御するモードを導入しています。
実装は activesupport/lib/active_support/ractors.rb に追加されており、おおまかに次の3モードがあります:
:raise- Rails が proc を
Ractor.make_shareableしようとする。 - 失敗した場合、そのまま
Ractor::IsolationErrorを「大きなエラー」として投げる。 - 意図: 問題のあるコードを早期に見つけて修正したい開発者向け(テストやCIで有効にする想定)。
- Rails が proc を
:warn- 同様に
Ractor.make_shareableを試みるが、 - 失敗 (
Ractor::IsolationError) したら rescue して 警告ログを出すだけ にする。 - 実行は継続するが、将来のRactor化に向けて「どこが危険か」を洗い出せる。
- 同様に
nil- Rails はそもそも
Ractor.make_shareableを試みない。 - 現状とほぼ同じ挙動で、Ractor セーフティのチェックを行わないモード。
- Rails はそもそも
このモードの設定インターフェースは、ActiveSupport::Ractors に隠蔽されている形で提供され、Rails 内部のさまざまなAPIから「今どのモードなのか」を参照しつつ処理が分岐する設計になっています。
適用箇所: RouteSet
このPRでは、まず ActionDispatch::Routing::RouteSet の一部コードパスでこの仕組みを利用するようになりました。
actionpack/lib/action_dispatch/routing/route_set.rbに 1行変更(実際には既存処理に対し、新しいRactorモードに応じて proc を shareable にする処理が追加)- 関連するテストが
actionpack/test/dispatch/routing/route_set_test.rbに 39行追加され、- Ractor モードごと(
:raise,:warn,nil)の挙動(エラーを投げる / warn を出す / 何もしない)を検証していると考えられます。
- Ractor モードごと(
ActiveSupport 側の実装
activesupport/lib/active_support/ractors.rb には主に以下が含まれていると考えられます(擬似イメージ):
module ActiveSupport
module Ractors
mattr_accessor :mode, default: nil
# mode: :raise, :warn, nil
def self.make_shareable(value)
case mode
when :raise
Ractor.make_shareable(value) # 失敗したらそのまま例外
when :warn
begin
Ractor.make_shareable(value)
rescue Ractor::IsolationError => e
Rails.logger.warn("Ractor unsafe proc detected: #{e.message}")
value # 処理続行
end
else # nil
value # 何もしない
end
end
end
end※上はPR説明とファイル構成からの推測コード例です。実際のメソッド名・インターフェースは若干異なる可能性がありますが、概ねこのような構造です。
これに対して activesupport/test/ractors_test.rb で:
ActiveSupport::Ractors.mode切り替え時の挙動Ractor::IsolationErrorの発生・rescue・warning 出力など
がテストされています。
- 影響範囲・注意点
現時点での実行時影響
- デフォルトモードが
nilであれば、既存アプリケーションの挙動は基本的に変わりません。 - ただし、Rails の内部で「今後 Ractor 対応を進める際のフック」となる処理が埋め込まれたため、将来的に設定を切り替えると影響が出ます。
開発者目線での使いどころ
- アプリを Ractor セーフにしたい、あるいはその準備状況を把握したい場合:
- ローカル or CI で
ActiveSupport::Ractors.mode = :raiseとしてテストを実行すると、- shareable でない proc を使っている箇所でテストが落ちるようになります。
- いきなりテストを落としたくない場合は
:warnでログ収集し、- 警告の出ている箇所を少しずつ修正していくという運用ができます。
- ローカル or CI で
Ractor セーフティ上の注意点
- proc の中でキャプチャしているオブジェクトが shareable かどうかが重要です。
代表的な問題パターン:- ルート定義や設定ブロックの中で、可変なオブジェクト(配列・ハッシュ・クラスインスタンスなど)をローカル変数に格納してクロージャで掴む。
- そのオブジェクトが Ractor 的に shareable にできない状態(ミューテックスや IO オブジェクトなどを含む)になっている。
- 今後、RouteSet 以外の Rails API についても、この仕組みを使って proc を shareable にしようとするようになる予定であり、その際に類似の問題が表面化する可能性があります。
バージョン互換性
- Ractor 自体は Ruby 3 以降の機能なので、古い Ruby バージョンでは(条件分岐により)無効 or 影響が限定的になるはずですが、
- 実際には Rails がどの Ruby 範囲をサポートしているかに依存します。
- このPR自体は、既存の挙動を壊さないよう opt-in な形で導入されているため、Rails をアップグレードしたタイミングで直ちに既存アプリが壊れるような変更ではありません。
- 参考情報 (あれば)
- PR本体:
- rails/rails #57626: Introduce a new mechanism for applications to prepare for ractor safety
- 関連クラス・ファイル:
activesupport/lib/active_support/ractors.rbactionpack/lib/action_dispatch/routing/route_set.rb
- Ruby Ractor 仕様:
Ractor.make_shareable(obj)… オブジェクトを Ractor 間で共有可能にする API。- 共有不能なオブジェクトを渡すと
Ractor::IsolationErrorが発生。
- 実務的な使い方の方向性:
- 将来の Ractor 対応を見据えて、まずは
:warnモードで運用し、「どの proc が Ractor unsafe か」をログベースで洗い出す。 - 問題のある箇所を修正できたら、CI で
:raiseに切り替えて「Ractor unsafe な proc を含む変更を検出する」仕組みとして利用する、という段階的導入が想定されます。
- 将来の Ractor 対応を見据えて、まずは
#57673 Reject attribute names that shadow CurrentAttributes methods
マージ日: 2026/6/11 | 作成者: @55728
- 概要 (1-2文で)
ActiveSupport::CurrentAttributesで、クラス内部のメソッド名と同じ名前の属性を定義できてしまう問題を修正し、そのような属性名を宣言した場合はArgumentErrorを投げて拒否するようにしました。これによりresetが効かなくなるなどの「リクエスト間で状態が漏れる」不具合を防ぎます。
- 変更内容の詳細
問題の内容
ActiveSupport::CurrentAttributes はリクエスト単位の状態を @attributes という内部ハッシュに持ち、attributes アクセサや reset などのメソッドで管理しています。
ところが、たとえば次のようにクラスを定義すると:
class C < ActiveSupport::CurrentAttributes
attribute :attributes, :foo
endattribute :attributes によって attributes という ユーザー定義アクセスメソッド が生成され、内部実装の attributes メソッドを上書きしてしまいます。その結果 reset が内部状態を正しくクリアできず、値がリクエスト間で残り続ける状態になります。
PRの説明の例:
require "active_support"
require "active_support/current_attributes"
C = Class.new(ActiveSupport::CurrentAttributes) do
def self.name; "C"; end
attribute :attributes, :foo
end
C.foo = 99
C.reset
p C.foo- 実際の挙動:
99が出力される(resetしても消えない) - 期待される挙動:
attribute :attributesを宣言した時点でArgumentErrorが発生する
同様に、以下のような危険なケースも存在していました。
attribute :defaults
→ 定義時点でNoMethodErrorでクラッシュattribute :attribute
→ 以後のattribute :xxx呼び出しが壊れる(API自体を壊す)
もともと INVALID_ATTRIBUTE_NAMES という「禁止属性名」の配列が手書きで管理されていましたが、
- 新たに追加された/変化したメソッド (
:attributes,:defaults,:attributeなど) がリストに入っていない - すでに削除されたメソッド (
:reset_all) が残り続けている
という「現状と乖離した状態」になっていました。
修正内容
1. 手書きの禁止リストを廃止し、動的に算出するよう変更
ActiveSupport::CurrentAttributes クラスの末尾で、クラス自身が定義しているメソッド名をすべて列挙し、それを禁止属性名として定数に保持するように変更しています。
具体的には、以下4種類のメソッドを対象にしています。
methods(false) +
singleton_class.private_instance_methods(false) +
instance_methods(false) +
private_instance_methods(false)methods(false)
→ クラスメソッド(public/protected)singleton_class.private_instance_methods(false)
→ クラスメソッドのうち private なものinstance_methods(false)
→ インスタンスメソッド(public/protected)private_instance_methods(false)
→ インスタンスメソッドのうち private なもの
いずれも (false) を渡して 継承元のメソッドは除外 しているのがポイントです。
ObjectやKernelが持っている一般的なメソッド(to_s,inspect, など)は禁止しない
→ ユーザー側でそれらと同名の attribute を定義したいケースを潰さないようにする- 代わりに、CurrentAttributes 自身が定義しているメソッドだけ を禁止対象にする
→ 内部APIとの衝突によるバグだけを確実に防ぐ
この動的な算出により、
- 今後
CurrentAttributesにメソッドを追加しても、自動的に禁止リストに反映される - 削除されたメソッドが禁止リストに残り続ける、というドリフトが発生しない
というメリットがあります。
2. 禁止された属性名が指定された場合に ArgumentError を投げる
クラス定義内の attribute 呼び出しで、上記で算出した禁止リストに含まれる名前が1つでも含まれていれば、ArgumentError を発生させます。
エラーメッセージは、複数指定された場合も含めてわかりやすい形です。例:
class C < ActiveSupport::CurrentAttributes
attribute :attributes, :foo
end
# => ArgumentError: Restricted attribute names: attributes
class C < ActiveSupport::CurrentAttributes
attribute :attribute, :foo, :defaults
end
# => ArgumentError: Restricted attribute names: attribute, defaults3. テストの追加
activesupport/test/current_attributes_test.rb に以下の回帰テストが追加されています。
attribute :attributes, :fooがArgumentError/Restricted attribute names: attributes/を投げることattribute :attribute, :foo, :defaultsがArgumentError/Restricted attribute names: attribute, defaults/を投げること
既存の「restricted attribute names」を検証するテストも継続して通っており、元から禁止されていた :set, :reset なども引き続き禁止されています。
- 影響範囲・注意点
破壊的変更 (Breaking Change) の可能性あり
既にActiveSupport::CurrentAttributesを継承したクラスで、以下のような属性名を定義していた場合、今回の変更後はクラス定義時にArgumentErrorが発生します。:attributes:attribute:defaults- その他
CurrentAttributesが定義しているクラスメソッド/インスタンスメソッドと同名のシンボル
ただし、これらの属性を使っていたアプリケーションは、もともと「
resetが効かない」「クラッシュする」「attributeDSL が壊れる」など、かなり危険な挙動になっていたはずなので、早期に例外で気付けるようになったとも言えます。Object/Kernelなど継承元のメソッド名とは衝突しない限り、普通の属性名に影響はありません。今回禁止されるのは、あくまでCurrentAttributes本体が定義しているメソッドと同名の属性だけです。新バージョンにアップデートした際に、アプリケーション内の CurrentAttributes サブクラス定義がロードされたタイミングで
ArgumentErrorが発生する可能性があるため、デプロイ前に CI でクラス定義の読み込みまで行うテストを回しておくと安全です。
- 参考情報 (あれば)
ActiveSupport::CurrentAttributesの仕組み- 各スレッド/リクエストごとに
@attributesというハッシュを持ち、attribute :fooでfoo,foo=,foo?などのアクセサを自動定義する仕組みです。 resetはこの@attributesを初期化することで「リクエスト間での状態の隔離」を保証しており、今回の PR はその根幹の安全性を高める変更です。
- 各スレッド/リクエストごとに
関連する過去の変更
reset_allメソッドはすでに475877fc5eで削除済みだったにもかかわらず、旧INVALID_ATTRIBUTE_NAMESには残っていたことから、手動管理のリストが現実と乖離しやすいことが分かります。今回の動的算出への移行は、この問題を根本的に解消する狙いがあります。
#57638 Refactor Composite Primary Key implementation by using polymorphism
マージ日: 2026/6/11 | 作成者: @paracycle
- 概要 (1-2文で)
このPRは、Railsの複合主キー(Composite Primary Key, CPK)対応で散在していた「if 複合主キーなら…」という条件分岐ロジックを整理し、新たに導入したActiveRecord::Keyクラス階層(ポリモーフィズム)に集約するリファクタリングです。機能追加というよりは、CPKサポートの内部実装を大幅にクリーンアップし、今後の保守性と拡張性を高める変更です。
- 変更内容の詳細
全体像
- これまで:
- 各所で
if primary_key.is_a?(Array)など、CPKを意識した分岐が多数存在 - 単一主キーと複合主キーの処理がベタな条件分岐で混在し、脆くなっていた
- 各所で
- このPR後:
ActiveRecord::Key抽象クラスと、そのサブクラス(単一キー用・複合キー用など)を導入- 「キーに関する操作」は
Keyオブジェクトに委譲し、呼び出し側はポリモーフィックに扱う - 関連、リレーション、トランザクション等に散らばっていた CPK 特有の条件分岐を削減
新規クラス: ActiveRecord::Key
activerecord/lib/active_record/key.rb に約 170 行の新ファイルが追加され、主キーの操作を統一的に扱うためのクラス階層が導入されています。
典型的には、次のような責務を持つと考えられます(コードから推測される役割):
- 単一主キーか複合主キーかを意識しないインターフェイス:
- 値の取り出し・設定
- WHERE 句や JOIN 条件に使うための「キー条件」の生成
- 関連レコードと元レコードのキー対応の作成 など
- 代表的なメソッドのイメージ:
#extract(record): レコードから主キー値を取り出す(単一なら1値、複合なら配列/ハッシュなど)#match?(record, attributes): 与えられた属性がこのレコードのキーにマッチするか#predicate_for(table): Arel用のキー比較条件を生成#present?/#blank?等、キーの存在チェック
呼び出し側は「キーの中身(単一 or 複合)」を知らずに key オブジェクトに処理を任せることができるようになります。
各コンポーネントでの具体的なリファクタリング
※ファイル名ごとに「何が変わったか」を要点だけ列挙します。実装のほとんどは ActiveRecord::Key に吸収されていると考えてください。
1. ActiveRecord::AttributeMethods::PrimaryKey (+10/-9)
primary_key/id周りで「配列なら…」といった CPK 判定を行っていた箇所をKey経由の処理に置き換え。id_in_databaseなど「DB上のキー値」を扱う処理が、単一/複合を意識せずに扱えるよう整理。
2. 関連(Associations)
activerecord/lib/active_record/associations/builder/belongs_to.rb(+2/-5)activerecord/lib/active_record/associations/collection_association.rb(+7/-19)activerecord/lib/active_record/reflection.rb(+24/-42)
これらでは主に次のような変更が入っています:
belongs_toやhas_many等の関連付けが、関連先の主キーが複合かどうかを直接意識せずに、reflection.keyもしくは類似の API を通してキー操作を行うように変更。- 外部キー生成や「関連をロードするためのクエリ」生成時の分岐ロジックを削減し、
ActiveRecord::Keyが返す条件(predicate)や値セットを利用するように。
結果として:
# 以前(イメージ)
if reflection.primary_key.is_a?(Array)
# CPK用の条件組み立て
else
# 単一主キー用
end
# 以後(イメージ)
reflection.key.build_predicate(relation)のように呼び出し側のコードがスリムになっています。
3. リレーション / Finder
activerecord/lib/active_record/relation.rb(+3/-17)activerecord/lib/active_record/relation/finder_methods.rb(+4/-22)activerecord/lib/active_record/relation/predicate_builder/association_query_value.rb(+4/-7)activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_value.rb(+1/-6)
これらは主に以下の点が整理されています:
find,exists?,whereなどで主キーを使うクエリ生成時に、「引数が配列で来たら CPK」などのベタな判定をせず、Key経由でクエリ条件を構築。PredicateBuilder周りのコードも、主キー構造を直接扱う代わりにKeyから提供される情報を使うよう改善。- ポリモーフィック関連のクエリ (
polymorphic_array_valueなど) においても、キー構造周りの条件分岐が削減。
4. 自動保存 / ネスト属性 / カウンタキャッシュ / トランザクション
activerecord/lib/active_record/autosave_association.rb(+1/-5)activerecord/lib/active_record/nested_attributes.rb(+5/-9)activerecord/lib/active_record/counter_cache.rb(+5/-12)activerecord/lib/active_record/transactions.rb(+5/-9)activerecord/lib/active_record/core.rb(+1/-5)
これらはいずれも:
- 「親子レコードの関連付け」「counter_cache のインクリメント対象判定」「トランザクション内でのレコード識別」など、主キーに依存する処理から CPK 特有の条件分岐を排除。
- レコード識別やキー比較、WHERE 条件生成を
Key経由に統一することで、コードが読みやすく・テストしやすくなるよう整理。
テスト
activerecord/test/cases/key_test.rb(+112/-0)
ActiveRecord::Key およびそのサブクラスの挙動を検証する新しいテストが追加されています。
想定されるテスト内容の例:
- 単一主キー・複合主キーで
#extract,#predicate_for,#match?などの挙動が正しいか - nil / blank / 型変換など、エッジケースで一貫した振る舞いをするか
- 関連・リレーション処理と組み合わせたときに期待通りのSQL条件が生成されるか
- 影響範囲・注意点
対利用者(アプリ開発者)視点
- 目に見える API の変更よりも内部実装の整理が中心のPRです。
- 公式にサポートされた CPK 機能を利用している場合:
- 動作仕様は基本的に変えず、安定性と一貫性を高めることを目的としたリファクタリングです。
- とはいえキー周りの広範な内部変更なので、CPK を使うモデルの関連・ネスト属性・counter_cache・finder については、アップグレード時にテストを厚めに回すことを推奨します。
内部API / メタプログラミングを多用している場合
- 以下のようなコードを書いている場合は注意が必要です:
primary_keyが配列かどうかを直接見て分岐するActiveRecord::Reflection/Associationの内部で primary key / foreign key の具体的な構造に依存したロジックを記述- ActiveRecord の内部クラスを monkey patch して CPK 対応を上書きしている
今回の変更により:
- 「CPK かどうか」を直接判定するのではなく、「Key オブジェクトを通して何がしたいか」を考えた方が、今後のバージョンでも壊れにくくなります。
- 内部構造に依存した独自実装は壊れる可能性があります。
ActiveRecord::Keyのインターフェイスに追随する形に書き換えることを検討してください。
gem 作者・拡張ライブラリ作者向け
- CPK をサポートする独自の関連・finder・スコープ生成ロジックを持っている gem は、
ActiveRecord::Keyの存在を前提に、実装を単一/複合を意識しない形にリファクタできる可能性があります。 - 将来的に:
- 例えば「UUID 主キー」「シャーディング用の複合キー」など、特殊なキー戦略を
ActiveRecord::Keyのサブクラスとして提供するような拡張もやりやすくなります。
- 例えば「UUID 主キー」「シャーディング用の複合キー」など、特殊なキー戦略を
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57638
- 背景:
- 以前の CPK 対応は「機能追加の第一弾」として条件分岐ベースで実装されており、コードベースが脆くなりやすい構造だった。
- このPRはその「後片付け」として、オブジェクト指向的な抽象化(
ActiveRecord::Key)により、CPK サポートを Rails 内部の一級機能として整理する段階と位置づけられます。
#57660 Fix normalizes casting raw database values on in-place change detection
マージ日: 2026/6/11 | 作成者: @chaadow
- 概要 (1-2文で)
normalizesを使った属性について、「インプレース変更の検知(normalized_attribute_changed_in_place?)」時に DBの生値(raw value)を誤って cast していたため JSON などで例外が出るリグレッションが発生しており、これを 正しく deserialize を通すように修正した PRです。
- 変更内容の詳細
問題の背景
normalizes は属性値を保存前などに正規化する仕組みですが、「その属性がインプレースで変更されたか?」を判定するために normalized_attribute_changed_in_place? が内部で呼ばれます。
この判定では、
- 「現在の値」と
- 「変更前の値」
を比較する必要があります。この「変更前の値」は attribute.value_before_type_cast から取られます。
ところが、DBから読み込まれた属性の場合:
value_before_type_castには シリアライズされた生のDB値(例: JSONカラムなら"{"foo":"bar"}"のような文字列)が入る- これに対して
attribute.type.cast(value_before_type_cast)を呼ぶと、typeによっては 文字列を解析せず、そのまま返す 実装になっている(ActiveRecord::Type::Json#castなど)
結果として、正規化ブロックには「期待している Ruby オブジェクト」ではなく「生の文字列」が渡ってしまい、以下のようなエラーが発生していました:
class User < ApplicationRecord
# settings は json カラム
normalizes :settings, with: -> settings { settings.transform_keys(&:downcase) }
end
user = User.find(User.create!(settings: { "foo" => "bar" }).id)
user.settings["BAZ"] = "qux" # インプレース変更
user.valid?
# => NoMethodError: undefined method 'transform_keys' for an instance of Stringsettings に期待しているのは Hash ですが、実際には "{"foo":"bar"}" のような String が渡ってきて transform_keys が呼ばれて落ちる、という状況です。
同様の問題は、「DBデフォルト値を持つ新規レコード」(例: jsonb デフォルト {})でも起きます。これも DB の生値から attribute が組み立てられるためです。
修正内容
normalized_attribute_changed_in_place? における「変更前の値」の型変換を以下のように変更:
- 変更前:
attribute.type.cast(attribute.value_before_type_cast) - 変更後:
attribute.type_cast(attribute.value_before_type_cast)
Attribute#type_cast は:
- DB由来の属性に対しては
type.deserialize(...)を呼ぶ - ユーザー入力由来の属性に対しては従来通り
type.cast(...)を呼ぶ
という分岐を内部で行うメソッドです。
このため:
- DBから読み込んだ JSON カラム →
deserializeが呼ばれ、JSON 文字列 → Ruby Hash へ正しく変換される - ユーザーが直接代入した値については、従来と同じく
castが使われる(挙動変更なし)
という形で、DB由来の属性だけ正しく扱えるようにしつつ、既存のユーザー代入経路への影響は避けています。
テスト
activerecord/test/cases/normalized_attribute_test.rb にテストが追加されています。
内容としては、少なくとも:
normalizesを持つ JSON(または類似シリアライズ型)属性- レコードを DB からロード
- 属性の中身をインプレースで変更
- 検証/保存時にエラーが出ないこと、および変更検知が正しく働くこと
といったシナリオをカバーしていると考えられます。
- 影響範囲・注意点
影響対象:
normalizesを利用していて- かつ対象カラムが JSON などのシリアライズ型(DB上は文字列やバイナリだが、Ruby では Hash / Array / オブジェクトにマッピングされる型)
- さらにその属性を インプレースで変更している (
user.settings["key"] = .../<</merge!など) ケース
改善内容:
- 上記ケースで、これまで
NoMethodErrorなどが出ていたのが解消され、期待どおりに正規化と変更検知が行われます。 - DBデフォルト値を持つ json/jsonb カラムでも、ロード直後のインプレース変更が安全に扱われます。
- 上記ケースで、これまで
後方互換性:
- ユーザーが代入した値(
user.settings = { foo: "bar" }など)に対しては従来と同じcastが使われるため、normalizesの挙動に変更はありません。 - 差し替えたのは「DB由来の
value_before_type_castをどう Ruby 型へ戻すか」という内部実装であり、外部APIは変わりません。
- ユーザーが代入した値(
注意点:
- もし独自の
ActiveModel::Typeを実装しており、cast/deserializeのどちらか一方だけを正しく実装していた場合、この変更により DB由来の値にはdeserializeが確実に使われる ため、deserialize側の実装が不十分だと逆に不具合が顕在化する可能性があります。 - ただしこれは ActiveRecord の型システムの本来の契約に沿った挙動であり、この PR 固有の奇抜な仕様変更ではありません。
- もし独自の
- 参考情報 (あれば)
- 回帰の原因となった PR: #57639
- 修正対象メソッドが含まれるファイル:
activemodel/lib/active_model/attributes/normalization.rb
- 型システムの関連メソッド:
ActiveModel::Attribute#value_before_type_castActiveModel::Attribute#type_castActiveRecord::Type#castActiveRecord::Type#deserialize
#57658 Add test coverage for ActiveSupport::Duration edge cases
マージ日: 2026/6/11 | 作成者: @hammadxcm
- 概要 (1-2文で)
ActiveSupport::Duration のこれまでテストされていなかったエッジケースや、rdoc で明示されている挙動に対してテストを追加する PR です。コード本体の変更はなく、テストのみの追加で信頼性と回 regresion 検知能力を高めています。
- 変更内容の詳細
この PR では activesupport/test/core_ext/duration_test.rb に 34 行のテストコードが追加されています。主な対象は、ActiveSupport::Duration の以下のメソッド・挙動です。
2-1. 単項マイナス -@ の挙動
目的
Duration に単項マイナスを適用したとき、
- 内部の
valueが反転する partsの各要素も符号反転する
という仕様がテストされていませんでした(+@のみカバーされていた)。
イメージコード例
duration = ActiveSupport::Duration.build(3600) # 1.hour 相当
neg = -duration
# 期待される挙動(仕様に基づく)
neg.value # => -duration.value
neg.parts # => duration.parts.transform_values { |v| -v }今回のテストでは、この「value も parts も両方が確実に反転しているか」を確認しています。
2-2. .build の分解ロジックのテスト
目的
.build は「秒数から Duration を組み立てる」クラスメソッドで、rdoc に以下のような分解例が書かれていますが、その具体的な parts の中身はテストされていませんでした。
例:
ActiveSupport::Duration.build(2716146).parts # => { months: 1, days: 1 }
(実際の rdoc に準じた形)
既存テストはエラーケースと Time/Date との演算結果だけを見ており、「どの単位にどう分解されるか(rdoc に示された例通りか)」は未カバーでした。
イメージコード例
duration = ActiveSupport::Duration.build(2_716_146)
duration.parts
# rdoc に書かれている例と一致することを確認するテストが追加されている
# 例: { months: 1, days: 1 } などこの PR で、rdoc に書かれている .build の分解例がそのままテストされ、仕様と実装の乖離があれば検知できるようになっています。
2-3. #parts が独立コピーを返すことの確認
目的
Duration#parts が「内部状態と独立したコピー」を返すことは暗黙/半ば明示的な挙動ですが、「返ってきた Hash を破壊的に変更しても、元の Duration の状態は変わらない」ことを保証するテストがありませんでした。
イメージコード例
duration = 1.day + 2.hours
parts = duration.parts
parts[:days] = 10
parts.delete(:hours)
duration.parts
# => 引き続き { days: 1, hours: 2 } のような元の内容であることを確認このテストによって、parts の返り値を書き換えても Duration 自体が汚染されないことが将来にわたって保証されます(内部実装で @parts をそのまま返してしまうようなリファクタをするとテストが落ちる)。
2-4. #<=> が非 Duration / 非 Numeric に対して nil を返すこと
目的
Duration#<=> は、比較対象が
DurationNumeric
の場合と、それ以外の場合で処理が分かれています。
この「それ以外」のパス(nilを返すべき)がテストされておらず、Scalar#<=>側のnilパスのみカバーされていました。
イメージコード例
duration = 1.day
duration <=> Object.new
# => nil であることを確認するテストが追加これにより、「Duration 同士・数値との比較以外は nil を返す」という比較演算子の仕様がテストで明示されました。
2-5. #coerce に Duration を渡した場合の挙動
目的
coerce は数値演算時に Ruby が内部で呼び出すメソッドで、Numeric との二項演算を整合させるために使われます。
既存テストでは Scalar#coerce のみカバーされており、Duration#coerce に Duration を渡したときの挙動が未テストでした。
仕様としては、Duration#coerce(other) が Duration を受け取ったとき、
- 相手側の値
otherをScalarでラップして返す
というパスがあります。
イメージコード例
d1 = 1.day
d2 = 2.days
left, right = d1.coerce(d2)
left # => ActiveSupport::Duration::Scalar のインスタンス(d2 をラップ)
right # => d1この PR は上記のような「Duration 同士の coerce の戻り値が Scalar を用いる」という仕様をテストで固定化しています。
- 影響範囲・注意点
- 影響範囲はテストコードのみで、本番コード(
ActiveSupport::Durationの実装)には一切変更がありません。 - 既存の
Duration利用コードが壊れることはありませんが、-@の符号反転仕様.buildの分解ロジック#partsのコピー性- 非 Duration / 非 Numeric との
<=>がnilになること - Duration 同士の
coerceがScalarを返すこと
といった挙動が、今後のリファクタで変わるとテストが落ちるようになります。
- そのため、これらは「事実上の仕様」としてより強く固定されます。
Rails 本体やアプリ側で Duration を拡張・モンキーパッチしている場合は、これらの前提を崩さないよう注意が必要です。
- 参考情報 (あれば)
- 対象 PR: https://github.com/rails/rails/pull/57658
- 対象クラス:
ActiveSupport::Duration- rdoc:
activesupport/lib/active_support/duration.rb
- rdoc:
- 関連する内部クラス:
ActiveSupport::Duration::Scalar(比較演算・coerce に関わる)
#57666 Make some more constants ractor safe
マージ日: 2026/6/11 | 作成者: @andrewn617
- 概要 (1-2文で)
Rails の Action Pack 周辺で、Ractor(マルチスレッド並列実行機構)対応を強化するために、一部の定数およびクラス属性を freeze して「Ractor セーフ(共有可能)」にした PR です。特に scaffold されたルーティング付きの新規アプリを Ractor 内で動かす際に頻繁にアクセスされる定数を対象にしています。
- 変更内容の詳細
※PR 本文と変更ファイル一覧から読み取れる範囲での解説です(実際の diff は省略されていますが、Rails の既存実装パターンから推測を含みます)。
2-1. 全体方針
- Ruby 3 以降の Ractor では、Ractor 間で共有できるオブジェクトは「shareable」である必要があります。
- shareable にする典型的な手段が「不変オブジェクト(freeze 済み)」にすることです。
- 「リテラルとしてその場で書かれている定数」の多くは既に freeze 済みだったが、
- 非リテラルから生成している定数
- あるいは内部に non-shareable な値を持つ定数 / cattr が取りこぼされており、それらを追加で freeze しています。
2-2. ファイルごとの変更イメージ
actionpack/lib/action_dispatch/http/param_builder.rb (+1/-1)
リクエストパラメータを組み立てる内部ユーティリティで、パラメータ名や区切り文字列などの定数がある箇所です。
ここに定義されている一部の定数が
freezeされました。例としては、下記のようなイメージです(擬似コード):ruby# 変更前(例) PARAM_KEY_SEPARATOR = "&" # 変更後(例) PARAM_KEY_SEPARATOR = "&".freeze実際にはもう少し複雑なオブジェクト(配列やハッシュ)や、メソッド呼び出し結果を代入している定数を
freezeしている可能性があります。
actionpack/lib/action_dispatch/http/request.rb (+3/-5)
ActionDispatch::Requestは Rack 環境変数のキー一覧や、内部で使うヘッダ名などの定数を多数持っています。ここで、Ractor 内でリクエストを扱う際に必ず通るような定数(例: ENV キー配列、マッピングハッシュなど)が
freezeされています。+3/-5という行数から、単純なfreeze追加だけでなく、以下のようなリファクタリングもありえます(例):- すでにどこかで
freeze済みだった重複処理の削除 - 定数 or クラス変数 →
mattr_accessor / cattr_accessorなどの見直しとfreeze追加
- すでにどこかで
典型的なパターン例:
ruby# 変更前(例) ENV_METHODS = %w[REQUEST_METHOD PATH_INFO].map(&:freeze) # 変更後(例) ENV_METHODS = %w[REQUEST_METHOD PATH_INFO].freezeのように、内側を個別に freeze するのではなく、外側の配列そのものを
freezeして shareable にする変更がよくあります。
actionpack/lib/action_dispatch/http/response.rb (+1/-1)
ActionDispatch::Responseで、ステータスコードやデフォルトヘッダなどを保存している定数・クラス属性があります。そのうち Ractor 内でも共有されうるデータ(例: デフォルトヘッダのハッシュ、既知ステータスコードマッピングなど)を
freezeしています。ruby# 変更前(例) DEFAULT_HEADERS = { "X-Frame-Options" => "SAMEORIGIN" } # 変更後(例) DEFAULT_HEADERS = { "X-Frame-Options" => "SAMEORIGIN" }.freeze
actionpack/lib/action_dispatch/journey/router/utils.rb (+2/-2)
Journey は Rails のルーティングエンジンで、
Router::Utilsにはパス/ルート関連のヘルパや正規表現・キャッシュ用定数などがあります。PR 説明から「scaffolded routes のリクエストパスで当たる定数」とのことなので、
- パスパラメータを解析するための正規表現
- URL パーツを join するための配列 / 文字列パターン などが freeze されたと考えられます。
例(擬似コード):
ruby# 変更前(例) PATH_SEPARATOR = "/" # 変更後(例) PATH_SEPARATOR = "/".freeze
2-3. cattr(クラス属性)の freeze
説明に「and one cattr」とあるので、
cattr_reader/cattr_accessor/mattr_*系で定義されたクラス属性のうち、Ractor で共有される可能性があるものに対して、セット時にfreezeしたか、初期値をfreezeしたと考えられます。典型的な変更例:
ruby# 変更前(例) cattr_accessor :route_constraints self.route_constraints = {} # 変更後(例) cattr_accessor :route_constraints self.route_constraints = {}.freeze
- 影響範囲・注意点
3-1. 主な影響範囲
- 対象は主に Action Pack (ActionDispatch) で、
- リクエスト (
ActionDispatch::Request) - レスポンス (
ActionDispatch::Response) - パラメータビルド (
ParamBuilder) - Journey ルーター (
Journey::Router::Utils) に関連する定数/クラス属性です。
- リクエスト (
- Rails アプリのリクエスト処理パス全般に関係しますが、「既存の定数を mutate しているようなコード」がなければ基本的には影響はありません。
3-2. 後方互換性の観点
定数や cattr に代入されているオブジェクトが
freezeされることで、- それを 破壊的に変更しようとしているアプリケーションコードや gem があれば、
FrozenErrorが発生する可能性があります。
- それを 破壊的に変更しようとしているアプリケーションコードや gem があれば、
例えば、Rails の内部定数ハッシュを直接書き換えるようなコードは危険です:
ruby# NG の例(今回の変更で FrozenError のリスクが上がる) ActionDispatch::Response::DEFAULT_HEADERS["X-Frame-Options"] = "ALLOWALL"→ こういったカスタマイズは、initializer で
config.action_dispatch系設定を使うなど、公式にサポートされた経路で行うべきです。
3-3. Ractor を使う場合のメリット
- この PR により、Rails アプリを Ractor 内で動かした際に、
- リクエスト〜ルーティング〜レスポンスの処理パスで参照する定数がより多く「shareable」になり、
- Ractor 起動時やリクエスト処理時のエラー(non-shareable なオブジェクトの共有エラー)が減ります。
- 作者が述べているように、
rails new+ scaffold した標準的なアプリ構成で Ractor 実行する際の実用性向上が主な狙いです。
- 参考情報 (あれば)
- Ractor と shareable オブジェクトの仕様:
- Ruby 公式ドキュメント(英語): https://docs.ruby-lang.org/en/master/doc/ractor_md.html
Ractor.shareable?,Object#freezeなどのメソッド仕様
- 類似の Rails 変更:
- すでに多くの「リテラル定数」が freeze 済みで、この PR はそれに続く第 2 弾という位置付け。
- Rails コードでのベストプラクティス:
- Rails の公開 API に含まれない内部定数・クラス変数を直接書き換えず、config/initializer/拡張ポイントを使うようにすると、今回のような freeze 系の変更にも強いコードになります。
#57663 Little clean up in cookies_test.rb
マージ日: 2026/6/11 | 作成者: @andrewn617
- 概要 (1-2文で)
cookies に関するテスト (cookies_test.rb) 内の重複コードをヘルパーメソッド化し、テストコードを整理・簡素化したPRです。挙動変更はなく、あくまでリファクタリング(クリーンアップ)が目的です。
- 変更内容の詳細
PR説明から読み取れるポイントは以下です。
a. @request.env["action_dispatch.foo"] アクセスの共通化
これまでテスト内で繰り返し書かれていたような:
foo = @request.env["action_dispatch.foo"]といったコードを、専用のヘルパーメソッドに置き換えています。
(実際のキー名は foo の部分が用途に応じて違う可能性がありますが、説明では例として "action_dispatch.foo" が挙げられています)
イメージとしては、テストクラス内に例えばこんな感じのメソッドを生やしている形です:
private
def foo
@request.env["action_dispatch.foo"]
endこれにより、テスト中では単に foo と書けばよくなり、以下のメリットがあります。
- テスト内での環境変数アクセスの記述が簡潔になる
"action_dispatch.foo"のようなマジックストリングを1箇所に集約できる- 将来キー名やアクセス方法を変更したい場合も、ヘルパーを変えるだけで済む
b. cookies = @controller.send :cookies の共通化
テスト内で頻繁に
cookies = @controller.send :cookiesという記述がされていたため、これもヘルパーメソッドとしてまとめています。
ただし、テストクラスにはすでに cookies というメソッドが存在しているため、ローカル変数 cookies を使い回すと混乱したり、既存のメソッドを上書きしてしまう懸念があります。
そのため、PRではテストクラスの private メソッドとして(名前はPR文脈から推測ですが)例えば:
private
def controller_cookies
@controller.send :cookies
endのようなメソッドを追加し、テスト中では
cookies = controller_cookiesまたは直接 controller_cookies を使う形に変更していると考えられます。
この変更により:
@controller.send :cookiesというメタプログラミング的な呼び出しがテスト中からほぼ消える- 「コントローラから取得する cookies」と「既存のテスト用
cookiesメソッド」をはっきり分けられる cookiesという共通的な名前に対する上書き・衝突のリスクを減らせる
c. 行数としては減少(+140 / -163)
差分としては、追加140行・削除163行で、全体としてはコード量がやや減っています。
重複していた env アクセスや @controller.send :cookies 呼び出しをまとめたことで、テストの見通しが良くなったと考えられます。
d. 文脈: 大きなPRのための下準備
作者は、より大きなPRの中で cookies_test.rb を触る必要があり、そこで見つけた「重複の多いテストコード」を先に分離してクリーンアップしています。
そのため、このPR自体は仕様変更やバグ修正ではなく、後続PRのための土台づくりという位置付けです。
- 影響範囲・注意点
本番コードへの影響
変更対象はactionpack/test/dispatch/cookies_test.rbのみであり、ライブラリ本体のコード(app/やlib/以下)は変更されていません。そのため、本番環境での cookies 挙動には影響しません。テストコードの挙動
実質的にはメソッド抽出のみで、テストのロジック自体(何をアサートしているか)は変えていないため、挙動変更はほぼないと見なせます。メソッド名の衝突に注意
cookiesという名前のメソッド/ローカル変数が既に存在したため、そこを避ける形でヘルパーを定義しています。
今後、このテストファイルを編集する際は:- 既に存在するヘルパー(
foo相当やcontroller_cookies相当)を再利用する cookiesというローカル変数・メソッド名をむやみに増やさない といった点に注意すると、さらにコードが分かりやすく保てます。
- 既に存在するヘルパー(
CHANGELOG なし
テストのみの変更で挙動変更もないため、CHANGELOG は更新されていません。ライブラリ利用者にとっては「特に通知すべき変更はない」という扱いです。
- 参考情報 (あれば)
- PRテンプレートのチェックリストでは、「単一目的のPRであること」「コミットメッセージに理由を含めていること」「テストの追加・更新」が満たされており、テストリファクタとして妥当な範囲の変更です。
- 今後、cookies 周りで新しいテストを追加する場合は、
@request.env[...]アクセスには既存のヘルパーを使う- コントローラ経由の cookies アクセスには
@controller.send(:cookies)のラッパーメソッドを利用する
といったスタイルに合わせると、ファイル全体の一貫性が保てます。
#57657 Support a single composite primary key id in delete
マージ日: 2026/6/10 | 作成者: @55728
- 概要 (1-2文で)
Rails の ActiveRecord において、複合主キーを持つモデルに対してModel.delete(record.id)を呼び出すとArgumentErrorが出ていた問題を修正し、destroyと同様に単一レコードの削除が正しく動作するようにしました。deleteの単一・複数 ID 指定の挙動が、複合主キー環境でも一貫するようになります。
- 変更内容の詳細
問題の挙動
複合主キー (例: ["author_id", "id"]) を持つモデル Cpk::Book で次のようなコードを書いたとします。
book = Cpk::Book.first
Cpk::Book.destroy(book.id) # => OK(既に動作している)
Cpk::Book.delete(book.id) # => ArgumentError が発生していた
# ArgumentError: Expected corresponding value for ["author_id", "id"] to be an Array原因は delete が受け取った引数をそのまま
where(model.primary_key => id_or_array)に渡していたことです。
複合主キーの primary_key は配列(例: ["author_id", "id"])であり、book.id も ["author_id_value", "id_value"] のような配列になります。
このとき where は「複合主キーのカラム配列」と「値」を zip してタプル扱いしますが、delete 側が単一レコードか複数レコードかを判別せずにそのまま渡していたため、単一レコードの値配列が「タプルの配列」ではなく「タプルそのもの」として扱われ、バリデーションに失敗して ArgumentError になっていました。
修正内容
destroy はすでに、「単一 ID」と「複数 ID(バッチ)」を区別し、複合主キーの場合は「配列の配列(= タプルの配列)」で扱うロジックを持っています。
この PR では delete にも同様の処理を入れ、単一の複合主キー ID が渡された場合に、それを 1 要素の配列でラップしてから where に渡すようにしています。
イメージとしては、複合主キーかつ単一レコードの場合に
# 以前: 直接渡していた
where(primary_key => [author_id_value, id_value])
# 変更後: 1 要素の配列で包む
where(primary_key => [[author_id_value, id_value]])という差分です。これにより、where から見れば「複合主キーのタプルが 1 つだけ入った配列」として扱えるので、内部ロジックと整合します。
仕様上の一貫性
これで、クラスメソッドの delete / destroy は以下のような一貫した API になります(単一主キー / 複合主キー問わず):
# 単一 ID
Model.destroy(id) # コールバックあり
Model.delete(id) # コールバックなし(DELETE 直発行)
# 複数 ID(バッチ)
Model.destroy([id1, id2])
Model.delete([id1, id2])複合主キーの場合は、id 自体が「タプル (配列)」であり、複数 ID のときは「タプルの配列」になりますが、それを意識せずに destroy と同じ感覚で delete を使えるようになりました。
テスト
activerecord/test/cases/persistence_test.rb に、複合主キーを持つモデルに対して Model.delete(record.id) を実行できることを検証するテストが追加されています。
これにより、destroy だけでなく delete についても、複合主キーの単一 ID 削除が継続的に保証されます。
- 影響範囲・注意点
影響範囲
- ターゲットは ActiveRecord のクラスメソッド
deleteのうち、複合主キーを持つモデルに単一 ID を渡したケースのみです。 - 単一主キーのモデルに対する
deleteの挙動は変わりません。 - 複合主キーでも、すでに動いていた「複数 ID 指定 (
delete([id1, id2]))」の挙動はそのまま維持されます。 - コールバックが走るインスタンスメソッド
record.destroy、クラスメソッドModel.destroyの挙動にも変更はありません。
- ターゲットは ActiveRecord のクラスメソッド
後方互換性
- これまで
Model.delete(record.id)がエラーで落ちていたコードが、今後は正常に削除されるようになります。 - 既存コードで
deleteのエラーを前提にしたワークアラウンド(例: rescue して別処理)があった場合、そのロジックが呼ばれなくなる可能性はありますが、もともとドキュメント上はdestroyと同じ ID 指定が想定されていたため、仕様としては修正・是正方向の変更です。
- これまで
注意点
- 複合主キー利用時は、単一レコード削除:
Model.delete(record.id)/ 複数削除:Model.delete([record1.id, record2.id])という形で使えるようになりますが、
各record.idが「タプル(配列)」、複数指定は「タプルの配列」となる点は依然として変わりません。 - コールバックや
dependent: :destroyを利用したい場合は、従来どおりdestroyを使う必要があります。今回の変更はあくまでDELETE直発行のdeleteに対する API パリティ改善です。
- 複合主キー利用時は、単一レコード削除:
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/57657
- 関連ドキュメント: Active Record クエリインターフェイス「
deleteとdestroyの違い」delete: コールバックなしで直接DELETE文を発行destroy: コールバック、関連削除 (dependent: :destroy) などを実行してから削除
- 背景: Rails 本体では複合主キーは公式サポート対象外に近い扱いですが、内部的には一部機能が動作しており、本 PR はそうした「半サポート状態」の中で
destroyとの仕様差分を解消するものです。
#57653 Keep composite primary key columns in the default attribute set
マージ日: 2026/6/10 | 作成者: @55728
- 概要 (1-2文で)
このPRは、複合主キーを持つモデルでselectによる部分読み出しを行った際、主キー列を参照するとActiveModel::MissingAttributeErrorが発生してしまう問題を修正し、単一主キーと同様にnilを返すようにしたものです。attributes_builderがデフォルト属性セットを構築する際に、複合主キー列も常に初期化対象に含めるようにしています。
- 変更内容の詳細
問題の挙動
単一主キーの場合:
# 主キー: id
Topic.select(:title).first.id
# => nil (MissingAttributeError にはならない)複合主キーの場合:
# 主キー: [:author_id, :id] など
book = Cpk::Book.select(:title).first
book.author_id
# => ActiveModel::MissingAttributeError: missing attribute 'author_id' for Cpk::Book同じ「partial select で主キー列を取得していない」状況でも、単一主キーは nil を返すのに対し、複合主キーでは MissingAttributeError が出てしまう不整合がありました。
原因
ActiveRecord はモデルの「常に初期化されるデフォルト属性セット」を attributes_builder を通して構成しています。その際、主キーは常に初期化対象に残し、それ以外のカラムを除外する、というロジックになっています:
# 修正前(イメージ)
defaults = _default_attributes.except(*(column_names - [primary_key]))ここで:
単一主キーの場合
primary_key # => "id"column_names - ["id"] # => ["title", "created_at", ...]
→ それらをexceptで除外するので、「主キー id だけは defaults に残る」という意図通りに動作。複合主キーの場合
primary_key # => ["author_id", "id"](Array)column_namesは["author_id", "id", "title", ...]などの String の配列column_names - [primary_key]は「String 配列 から [Array] を引く」形になるため、何もマッチせず、結果はcolumn_namesのまま(= 何も引かれない)。
→defaults = _default_attributes.except(*column_names)となり、全カラムが defaults から除外されてしまう。
→ 主キー構成要素(author_id,id)も defaults に残らないため、partial select で読み込んでいない主キー列を参照するとMissingAttributeErrorになる。
修正内容
primary_key を常に配列として扱うようにして、複合主キーの各列もちゃんと保持されるようにしました。
# 修正後
defaults = _default_attributes.except(*(column_names - Array(primary_key)))単一主キー:
primary_key # => "id"Array(primary_key) # => ["id"]
→ 挙動は従来と完全に同じ。複合主キー:
primary_key # => ["author_id", "id"]Array(primary_key) # => ["author_id", "id"]column_names - ["author_id", "id"]により、主キー構成要素を除いたカラムだけが除外対象になる。
→defaultsには主キー列が残り、「常に初期化される属性セット」に主キー列が含まれるようになる。
このロジックは、同じファイルにある _returning_columns_for_insert では既に Array(primary_key) を使っており、それに揃えた形です。
テスト
activerecord/test/cases/primary_keys_test.rb にテストが追加されています(+7行)。
内容としては、「複合主キーを持つモデルで partial select を行った場合に、主キー列を読み出しても MissingAttributeError にならず nil が返る」ことを検証するものと考えられます。
- 影響範囲・注意点
対象:
- ActiveRecord で 複合主キー (composite primary key) を使用しているモデル。
- 特に
select(:title, ...)のような partial select を使いつつ、読み込んでいない主キー列にアクセスするコード。
挙動の変更点:
- これまでは、複合主キーの一部カラムを
selectに含めていない場合に、そのカラムを読もうとするとActiveModel::MissingAttributeErrorが発生していました。 - このPR以降は、単一主キーと同様に「レコードの属性としては存在しているが値は読み込まれていない」扱いとなり、読み出し時は
nilが返るようになります。
- これまでは、複合主キーの一部カラムを
後方互換性の観点:
- 「MissingAttributeError が発生すること」を前提にして例外処理を書いているコードがあれば、挙動が変わる可能性があります(例外が出ずに
nilになる)。 - 一方で、単一主キーではもともと
nilを返していたため、「主キー種別によって挙動が違う」という不整合が解消される形であり、多くのアプリケーションにとっては望ましい変更と考えられます。 - 何らかの理由で「複合主キー列が必ずロードされていること」を期待している場合は、
selectにその列を含めるか、select自体を見直す必要があります。今後は「ロードしていない主キー列はnilで返る」ことを前提にすべきです。
- 「MissingAttributeError が発生すること」を前提にして例外処理を書いているコードがあれば、挙動が変わる可能性があります(例外が出ずに
- 参考情報 (あれば)
- 該当箇所のコード:
activerecord/lib/active_record/model_schema.rbのattributes_builder周辺。 - 複合主キーを扱う場合、ActiveRecord本体は単一主キー前提の設計が多いため、今回のように
Array(primary_key)で吸収するパターンが他にも存在します(今回も_returning_columns_for_insertに揃えた形)。 - この修正により、主キーが単一か複合かに関わらず「partial select → 主キー列アクセス」の挙動が統一されるため、アプリケーションレベルでの扱いがシンプルになります。
#57649 Return an empty array from find([]) on a composite primary key
マージ日: 2026/6/10 | 作成者: @55728
- 概要 (1-2文で)
複合主キーを持つモデルに対してModel.find([])を呼ぶとActiveRecord::RecordNotFoundが発生していた挙動を、単一主キーと同様に空配列[]を返すように修正した PR です。これにより、主キーの種類に依存しない一貫したfindの挙動が保証されます。
- 変更内容の詳細
これまでの挙動の違い
単一主キーのモデル:
Topic.find([]) # => []複合主キーのモデル:
Cpk::Book.find([])
# => ActiveRecord::RecordNotFound
# Couldn't find all Cpk::Books with '["author_id", "id"]': ()
# (found 0 results, but was looking for 1)同じ find([]) 呼び出しでも、主キーが複合かどうかで挙動が変わっていました。
多くのコードでは以下のように「前段の検索結果の ID をそのまま find に渡す」パターンがあります:
ids = Cpk::Book.where(published: true).pluck(:author_id, :id)
records = Cpk::Book.find(ids) # ids が空配列のときも自然に動いてほしいこのとき ids が空配列でも、そのまま find(ids) して安全に [] を返してほしい、というのが今回の問題意識です。
問題の原因
ActiveRecord::Relation::FinderMethods#find_with_ids は、渡された ids の「形」を見て、「配列を返すべきかどうか」を判定しています(= 呼び出し元が単数レコードを期待しているのか、複数レコードを期待しているのかを推測している)。
複合主キーの場合、もともと次のようなロジックになっていました:
expects_array = ids.first.first.is_a?(Array)複合主キーの単一レコード指定:
find([1, 2])idsは[[1, 2]]ids.firstは[1, 2]ids.first.firstは1(Array ではない)→expects_array == false
複合主キーの複数レコード指定:
find([[1, 2], [3, 4]])idsは[[[1, 2], [3, 4]]]のような形になるケースを想定ids.first.firstは[1, 2](Array)→expects_array == true
しかし find([]) の場合:
ids = []
ids.first # => []
ids.first.first # => nilnil.is_a?(Array) は false なので「単一の複合キーが指定された」と誤認識され、「1件見つかるはず」として動いてしまい、結果として RecordNotFound が発生していました。
修正内容
空配列が渡された場合にも「配列を期待している」と判定するように条件式を修正しました。
修正後のロジック(概要):
expects_array =
ids.first.is_a?(Array) &&
(ids.first.empty? || ids.first.first.is_a?(Array))ポイント:
ids.first.is_a?(Array)
まず最初の要素自体が Array かをチェック(ids.first.empty? || ids.first.first.is_a?(Array))ids.first.empty?→[]の場合(= 今回のfind([]))はここで true となり、配列を期待していると判定- それ以外でも、
ids.first.firstが Array(=[[1,2],[3,4]]のような「複合主キーの配列の配列」)なら配列を期待と判定
この結果、以下のような挙動になります:
# 変更後
# 単一主キー
Topic.find([]) # => []
# 複合主キー (今回修正された部分)
Cpk::Book.find([]) # => [] # 以前は RecordNotFound
# 非空の複合主キー指定の挙動は従来どおり
Cpk::Book.find([1, 2]) # => 単一レコード (期待どおり)
Cpk::Book.find([[1, 2], [3, 4]]) # => 複数レコード配列 (期待どおり)テストとしても activerecord/test/cases/finder_test.rb に、複合主キーで find([]) が空配列を返すことを確認するケースが追加されています。
- 影響範囲・注意点
挙動が変わるケース
- 複合主キーのモデルで
Model.find([])を呼んだときに、これまではActiveRecord::RecordNotFoundだったものが、今後は[]を返すようになります。 - もし以前の例外発生に依存したロジック(例外を rescue して処理分岐するなど)がある場合は、挙動が変わる点に注意が必要です。
- 複合主キーのモデルで
挙動が維持されるケース
- 単一主キーのモデルに対する挙動は変わりません。
- 複合主キーに対しても、
find([1, 2])やfind([[1, 2], [3, 4]])など、非空の引数の挙動は変わりません。 find(nil)やfind(1)といった他のパラメータ形状については、この PR による影響はありません(従来どおり)。
実務的な影響
- 「前段の検索結果(空かもしれない)をそのまま
findに渡す」コードが、単一主キー・複合主キー問わず同じように安全に書けるようになります。 - 複合主キー対応のコードパスを書くときに、「空配列の場合だけ特別扱いする」といったワークアラウンドは不要になります。
- 「前段の検索結果(空かもしれない)をそのまま
- 参考情報 (あれば)
- 対象メソッド:
ActiveRecord::Relation::FinderMethods#find_with_ids - 主に参照するとよいファイル:
activerecord/lib/active_record/relation/finder_methods.rbfind/find_with_idsの ID 解析ロジック
activerecord/test/cases/finder_test.rb- 複合主キー周りの
findの期待挙動を確認するテストが追加されています。
- 複合主キー周りの
- 背景となる API 仕様:
- Rails では
Model.find([])は「ID が空のときは空配列を返す」という仕様であり、これが主キーの種類に関わらず一貫するように揃えられた修正です。
- Rails では
#57594 Fix bug where reload leaks ordinary scopes into all_queries lookups.
マージ日: 2026/6/10 | 作成者: @andrewn617
- 概要 (1-2文で)
reload実行時に、all_queries用のデフォルトスコープだけを使うはずのクエリに、通常のスコープ(current_scope)が紛れ込んでしまうバグを修正した PR です。allを経由せずdefault_scoped(all_queries: true)を直接使うことで、all_queriesなスコープだけが正しく反映されるようになりました。
- 変更内容の詳細
問題になっていた挙動
Active Record の reload は、all_queries デフォルトスコープを考慮してモデルを再読込する際に、内部的に以下のような流れになっていました(擬似コード):
# 以前のイメージ(問題があるパターン)
def reload(...)
relation = all(all_queries) # ← all を経由
# ...
endしかし Relation#all は current_scope(現在適用中のスコープ)を引き継ぐ性質があります。そのため:
CurrentScopeModel.where(published: true).scoped do
post = Post.first
post.reload
endのような状況で、Post の reload 時のクエリに、Post 本来の all_queries デフォルトスコープに加え、where(published: true) など「意図しない通常スコープ」が混入する可能性がありました。
本来 reload で参照したいのは「all_queries: true 対象のデフォルトスコープ」と「all_queries 指定のグローバルスコープ」だけです。
修正内容
PR では、reload 内部のクエリ生成を以下のように変更しています(概念的なイメージ):
# 新しいパターン(問題の修正)
def reload(...)
# all(all_queries) をやめて、直接 all_queries 向けの default_scoped を使う
relation = default_scoped(all_queries: true)
# さらに current_global_scope を調べて、
# all_queries:true なスコープのみをマージするようにする
if current_global_scope&.all_queries?
relation = relation.merge(current_global_scope)
end
# relation を使ってレコードを再取得
endポイント:
all(all_queries)の代わりにdefault_scoped(all_queries: true)を直接呼び出すことで、current_scope由来の通常スコープが混入しないようにした。- その上で、
current_global_scopeの中からall_queriesフラグが立っているものだけを取り込むようにしているため、「all_queriesなデフォルトスコープ/グローバルスコープだけを使ったクエリ」でreloadされる。 activerecord/test/cases/scoping/default_scoping_test.rbにテストが追加され、この漏れ込みバグが再発しないことを確認している。
※ 実際のメソッド名・ロジックは上記より細かいですが、意図としては「reload における all_queries 専用のクエリ生成経路を明確に分けた」と理解しておくとよいです。
CHANGELOG
activerecord/CHANGELOG.md にエントリが追加され、reload と all_queries スコープに関するバグ修正として明示されました。
- 影響範囲・注意点
- 影響を受けるケース
default_scope -> all_queries: trueといった仕組みを利用しているモデルで、- かつ、
current_scopeやグローバルスコープが有効なコンテキストでreloadを呼んでいる場合。
- 期待される変更
- これまでは、
reload時のクエリに「現在の通常スコープ」が紛れ込んでいた可能性があり、その結果として:- 想定より絞り込まれた条件で再読み込みされる
- 条件にマッチせず
reload時の挙動が不自然になる
- といった挙動が、今回の修正で「
all_queries向けのデフォルトスコープ+all_queriesなグローバルスコープのみ」に限定されます。
- これまでは、
- 注意点
- もしアプリ側のコードが「
reloadが現在の通常スコープを暗黙に引き継ぐ」ことを前提にしてしまっていた場合、その前提が壊れる可能性があります(ただし、この前提自体がバグ依存の挙動です)。 reloadの挙動変更によりテストが落ちた場合、reloadにスコープが効いているかどうかをテストしていないか確認するとよいです。reloadは「DB の最新状態をそのレコードの主キーで取り直す」動きを想定すべきであり、任意スコープの効果には依存しない方が安全です。
- もしアプリ側のコードが「
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/57594
- 関連 PR(バグ発見のきっかけになったもの): https://github.com/rails/rails/pull/57123
all_queriesは Rails 7.2 以降で進められているスコープの分離・制御まわりの一部で、default_scopeやグローバルスコープを「すべてのクエリに効かせるか」「特定のクエリ API のみに効かせるか」を細かく制御するための仕組みです。
#57637 Reinitialize the thread-safe level key when a logger is copied
マージ日: 2026/6/10 | 作成者: @etiennebarrie
- 概要 (1-2文で)
LoggerThreadSafeLevelを持つロガーをclone/dupした際に、元のロガーとスレッドローカルなログレベルの保存領域を共有してしまうバグを修正しています。コピー時にキーを再初期化することで、コピー後のロガーごとに独立したスレッドセーフなログレベル管理が行われるようになります。
- 変更内容の詳細
背景: 何が問題だったか
ActiveSupport::LoggerThreadSafeLevel は「ロガーごと」にスレッドローカルなログレベルを保持する仕組みを持っています。
ここでの実装は:
- スレッドローカル変数 (
Thread.current[...]) を使う - そのキーとして、ロガーインスタンスの
object_idから導出した値 を使う
という設計になっていました。
ところが、Ruby の clone / dup は:
- 新しいオブジェクトを作るため
object_idが変わる - しかしインスタンス変数はコピーされる
ため、以下のような状態になっていました:
- オリジナルロガー A が
@thread_safe_level_keyを持っている (object_id A に紐づくキー) A.cloneしてロガー B を作ると、A と同じ@thread_safe_level_keyを持った B ができる- しかし B 自身の
object_idは A と異なる - その結果、本来「ロガーごと」に分離されるはずのスレッドローカルなログレベルの保存領域が A と B で共有されてしまう
とくに #log_at などで一時的にログレベルを変えるようなコードを書くと、A/B どちらかの操作がもう一方に影響する(互いに上書きしあう)という、直感に反するバグが起きます。
修正内容
修正は非常にピンポイントで、LoggerThreadSafeLevel のコピー時初期化フック #initialize_copy をオーバーライドし、#initialize 同様にキーを再生成するようにした、というものです。
対象ファイル:activesupport/lib/active_support/logger_thread_safe_level.rb
主な変更点(要約):
# 疑似コードレベルのイメージ
class ActiveSupport::LoggerThreadSafeLevel
def initialize(*)
super
reinitialize_thread_safe_level_key
end
def initialize_copy(other)
super
# ここを今回追加
reinitialize_thread_safe_level_key
end
private
def reinitialize_thread_safe_level_key
@thread_safe_level_key = :"logger_thread_safe_level_#{object_id}"
end
end実際のコードでは reinitialize_thread_safe_level_key のような名前かはともかく、考え方としては:
#initializeでやっている「object_id に基づくキー生成」を#initialize_copyでも呼ぶ
という形で、「clone/dup 後のオブジェクトは必ず自分専用のキーを持つ」ことを保証しています。
テストの追加
対象ファイル:activesupport/test/logger_test.rb
テストでは、主に以下のようなシナリオを確認していると考えられます:
- ロガー
logger1を作成し、そのクローンlogger2 = logger1.cloneを作る - 各ロガーで
log_atやスレッドローカルレベルを設定 logger1とlogger2のログレベル操作が相互干渉しないことを検証
例として、構造イメージは次のようなものです(実際のテスト名は多少違う可能性があります):
def test_cloned_logger_has_independent_thread_local_level
logger1 = ActiveSupport::TaggedLogging.new(Logger.new(StringIO.new)).logger
logger2 = logger1.clone
logger1.log_at(:debug) { ... }
logger2.log_at(:info) { ... }
# それぞれの logger に対して、設定したレベルが期待通りに効いていることを検証
# かつ、一方を変えてももう一方に影響しないことを検証
end実テストでは、より具体的に「一定メッセージが出る/出ない」をアサートしているはずです。
- 影響範囲・注意点
影響範囲
- 影響を受けるのは ActiveSupport のスレッドセーフロガーレベル機能(
LoggerThreadSafeLevel)を利用しつつ、ロガーをcloneまたはdupしているコード です。 - Rails 標準のロギング(
Rails.logger)でも内部的にこの仕組みを使っているため、アプリケーションやライブラリがロガーのコピーを行っている場合に挙動が変わります。
具体的な挙動の変化
以前:
logger2 = logger1.cloneとした場合、logger1.log_atとlogger2.log_atが同じスレッドローカルストレージ領域を共有してしまう- そのため、
logger2で一時的にログレベルを変更したつもりが、logger1側のログ出力にも影響しうる
修正後:
logger1とlogger2は 異なるキーでスレッドローカルレベルを管理する ため、互いのlog_atが干渉しなくなる- 「ロガーインスタンスごとに独立したログレベルが保たれる」という自然な期待に沿った挙動になる
注意点
- もし既存コードが「clone したロガーは元ロガーとレベルを共有する」という、これまでの誤った挙動に依存していた場合は、挙動が変わります。ただし、そのような依存はまず意図的ではないと考えられ、今回の変更はバグフィックス扱いと見て良いです。
Logger自体の API(log,info,debugなど)や、log_atの表向きの仕様は変わっていません。変わるのは clone/dup 時の内部的なスレッドローカル管理キーのみです。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57637
- 関連する内部実装:
ActiveSupport::LoggerThreadSafeLevelLogger#clone,Logger#dupとinitialize_copyフック
- 類似のパターン:
object_idに基づいたキャッシュキーや Thread-local キーを使うクラスでは、initialize_copyでの再初期化を行わないと同種の問題が起きることがあります(例: メモ化キャッシュ、ミドルウェアの状態保持など)。
#57647 Don't mutate the names array passed to Cache::Store#delete_multi
マージ日: 2026/6/10 | 作成者: @55728
- 概要 (1-2文で)
ActiveSupport::Cache::Store#delete_multiが、呼び出し元から渡された配列を破壊的に変更していた問題を修正し、他の multi 系メソッド (read_multi,write_multi,fetch_multi) と同様に非破壊的な処理に統一する PR です。これにより、キー配列が意図せず変更されたり、名前空間が二重に付与される不具合や、凍結配列での例外が解消されます。
- 変更内容の詳細
問題点
これまでの delete_multi の実装では、内部的に以下のようなことをしていました(概念的なコード):
def delete_multi(names, options = nil)
# 実際には normalize_options などがありますが要点のみ
names.map! { |key| normalize_key(key, options) } # ここが破壊的
delete_multi_entries(names, options)
endこのため:
cache = ActiveSupport::Cache::MemoryStore.new(namespace: "ns")
names = ["foo", "bar"]
cache.write("foo", 1)
cache.write("bar", 2)
cache.delete_multi(names)
names # => ["ns:foo", "ns:bar"] になってしまうというように、呼び出し側が渡した names 配列の中身自体が、名前空間付きの内部キーに書き換わっていました。副作用として:
- 配列再利用時に二重 namespace 化されるruby
# 1回目で names が ["ns:foo", "ns:bar"] に書き換わっている cache.write("foo", 1) cache.write("bar", 2) cache.delete_multi(names) # => 0 (ns:ns:foo を探しに行く) cache.exist?("foo") # => true (削除されていない) - 凍結された配列を渡すと例外が発生するruby
cache.delete_multi(["foo", "bar"].freeze) # => FrozenError: can't modify frozen Array: ["foo", "bar"]
一方で、read_multi, write_multi, fetch_multi などの兄弟メソッドは、いずれも新しい配列・ハッシュを作って処理しており、引数を破壊的に変更しません。この不整合が今回の修正対象です。
修正内容
names.map! をやめて、非破壊的な map に変更し、その結果をローカル変数に再代入する形に変更されています。
修正後のイメージ:
def delete_multi(names, options = nil)
# ...
names = names.map { |key| normalize_key(key, options) }
delete_multi_entries(names, options)
endこれにより:
- 呼び出し側が渡した配列は、内容が変更されない
- 内部で使うのは normalized 済みの別配列になる
という挙動になります。PR 説明にもあるように:
Nothing downstream relies on the mutation —
delete_multi_entries... only iterates over the array it receives, andinstrument_multionly reads it for the event payload.
つまり、delete_multi_entries や instrument_multi など後続処理は「渡された配列を読むだけ」で、破壊的変更に依存していないため、この変更による副作用はありません。
テスト追加
activesupport/test/cache/behaviors/cache_store_behavior.rb にテストが追加されています。テストでは主に以下を検証しているはずです(差分行数からの推定を含む):
delete_multiに渡した配列の中身が呼び出し後も変わらないこと- namespace 付きの store でもキー配列が書き換わらないこと
- (場合によっては)凍結済み配列でもエラーにならずに動作すること
- 影響範囲・注意点
既存アプリ側への直接的な影響
- 破壊的変更が「バグ」とみなされる挙動だったため、ほとんどのコードにとっては 挙動が改善するだけ です:
delete_multi呼び出し後に、渡した配列を引き続き生のキー配列として安全に使えるようになります。- 凍結された配列 (
["foo", "bar"].freeze) をdelete_multiに渡してもFrozenErrorが出なくなります。
- 破壊的変更が「バグ」とみなされる挙動だったため、ほとんどのコードにとっては 挙動が改善するだけ です:
delete_multiに副作用を期待していたコード
非推奨なパターンではありますが、以下のように「delete_multi呼び出し後、キー配列が内部キーに置き換わっている」ことを前提にしていたコードがあれば、挙動が変わります:rubynames = ["foo", "bar"] cache.delete_multi(names) # 旧挙動では names == ["ns:foo", "ns:bar"] を期待できた do_something_with_internal_keys(names)そのようなコードは今回の変更後に動作しなくなるため、
normalize_key相当の処理を自分で呼ぶ(もしくは内部キーに依存しない設計にする)必要があります。ただし、Rails の公開 API としては内部キーの形に依存するのは想定されていないため、そのようなコードはもともと壊れやすい書き方です。multi 系メソッドの一貫性が向上
read_multi,write_multi,fetch_multi,delete_multiがいずれも「引数コレクションを破壊しない」という仕様で統一されます。
これにより、「配列や enumerable をキャッシュキーとして何度も再利用する」ようなパターンも安心して書けます。
- 参考情報 (あれば)
- 対象メソッド:
ActiveSupport::Cache::Store#delete_multi - 関連メソッド:
ActiveSupport::Cache::Store#read_multiActiveSupport::Cache::Store#write_multiActiveSupport::Cache::Store#fetch_multi
- 影響ストア実装:
- 基底実装 (
ActiveSupport::Cache::Store#delete_multi_entries) RedisCacheStoreのdelete_multi_entriesオーバーライド
いずれも、渡された配列を「読むだけ」であり、今回の変更で壊れないことが PR 内で確認されています。
- 基底実装 (
#57643 Add test coverage for the remaining Type::Date cast branches
マージ日: 2026/6/10 | 作成者: @hammadxcm
- 概要 (1-2文で)
ActiveModel::Type::Date#cast の、これまでテストされていなかった分岐パスに対してテストを追加し、分岐網羅に近づけた PR です。アプリケーションコードの変更はなく、テストのみの追加です。
- 変更内容の詳細
対象: ActiveModel::Type::Date#cast(正確には内部の cast_value メソッド)の残りの分岐に対するテスト追加。
変更ファイル: activemodel/test/cases/type/date_test.rb に 25 行のテストコードを追加。
既存テストでは、以下のみがカバーされていました:
nil/ blank / パース不能文字列- ISO フォーマット文字列("YYYY-MM-DD")
- multiparameter 属性(
{ 1 => "2000", 2 => "1", 3 => "1" }のようなハッシュ)
今回の PR では、それ以外の「別のコードパス」がテスト対象に追加されています。
追加でカバーされた分岐は以下の 4 つです。
(1) to_date を持つ値のキャスト
仕様:
String以外の値で、#to_dateメソッドを持つもの (Time,Date,DateTimeなど) はvalue.to_dateでキャストされる。
テスト内容のイメージ:
type = ActiveModel::Type::Date.new
time = Time.utc(2020, 1, 2)
assert_equal Date.new(2020, 1, 2), type.cast(time)
date = Date.new(2020, 1, 2)
assert_equal date, type.cast(date)目的:
- ドキュメントに書かれている「その他の値は
to_dateでキャストされる」挙動を明示的に保証。
(2) 非 ISO だがパース可能な文字列 → フォールバックパーサ
仕様:
"2020-01-02"のような ISO 形式はISO_DATEの高速パスで処理されるが、
それ以外の文字列で、Date.parse等を使えば解釈可能なものはfallback_string_to_dateルートでパースされる。
テスト内容のイメージ:
type = ActiveModel::Type::Date.new
# 例: "1/2/2020" のようなロケール依存だが Ruby の Date.parse で解釈可能な形式
assert_equal Date.new(2020, 1, 2), type.cast("1/2/2020")目的:
- ISO 形式専用の高速パスとは別に、「フォールバックの文字列パーサ」が実際に通ることをテストで保証。
- 将来的なリファクタ時に、このパスが死んだコードになっていないか検知しやすくする。
(3) 文法的には正しいがカレンダー上は不正な日付 → nil
仕様:
"2008-02-31"のように「YYYY-MM-DD」としては正しいが、存在しない日付の場合、Date.new(year, month, day) rescue nilによってnilを返す。- これは
"ABC"のような完全にパース不能な文字列とは別の分岐。
テスト内容のイメージ:
type = ActiveModel::Type::Date.new
assert_nil type.cast("2008-02-31")区別されるケース:
"ABC"→ 早い段階の「nil-parts guard」でパース不能と判断されてnil。"2008-02-31"→ 数値3つは取れるので一度Date.newを試みるが、例外になりrescue nilでnil。
目的:
- 「形式的には正しいが存在しない日付」というケース専用のガードが正しく機能していることを保証。
(4) String でも to_date でもない値 → そのまま返す
仕様:
- 値が
Stringではなく、かつ#to_dateも実装していない場合、castはその値を変更せずに返すelse分岐がある。
テスト内容のイメージ:
type = ActiveModel::Type::Date.new
obj = Object.new # to_date を持たない任意オブジェクト
assert_same obj, type.cast(obj)目的:
- 変換対象外の値に対しては無理にキャストを試みず、そのまま返すという現在の仕様を固定化。
- 将来この分岐が変わった場合(例: 例外を投げるように変える等)にテストが教えてくれるようにする。
- 影響範囲・注意点
- 本 PR はテストコードのみの変更であり、
ActiveModel::Type::Dateの挙動そのものは従来から存在したものです。
→ これまで「暗黙に依存していた挙動」が、明示的にテストで固定された形になります。 - もし既存アプリケーションが
- 「to_date を持つ独自クラスを date 型にマッピングしている」
- 「非 ISO フォーマット文字列を渡している」
- 「不正日付/to_date 未実装オブジェクトを通している」 といったケースに依存している場合でも、挙動は変わりませんが、
- 将来の Rails バージョンでこのあたりを仕様変更しようとするとテストが落ちるため、挙動変更には明示的な判断が必要になります。
開発者としての実務的な読み方:
- 「いまの Rails は、date キャストにおいて上記 4 ケースをこのように扱う」と仕様がテストで確定した、と理解しておくとよいです。
- 特に、「to_date を持たない非 String 値はそのまま返す」という仕様は、型チェックやバリデーション設計時に前提としておけます。
- 参考情報 (あれば)
- 対象コード:
ActiveModel::Type::Date(activemodel/lib/active_model/type/date.rb) - 既存ドキュメント:
- Rails Guides: Active Record Validations and Callbacks(date 型のキャストの挙動)
- API Docs:
ActiveModel::Type::Date#cast/#cast_value
- 関連する典型的な使用例:
- モデル属性で
attribute :published_on, :dateとしている場合のパラメータキャスト - form から送られてくる
"YYYY-MM-DD"以外の文字列やTimeオブジェクトの扱い確認用テストを書きたい場合に、この PR のテストを参考にできます。
- モデル属性で
#57644 Memoize ivar when freezing ActiveSupport::TaggedLogging::Formatter
マージ日: 2026/6/10 | 作成者: @etiennebarrie
- 概要 (1-2文で)
ActiveSupport::TaggedLogging::Formatterを freeze した状態でも Ractor 間で共有して使えるようにするため、インスタンス変数のメモ化処理が追加されました。これにより、Ractor-shareable な logger 構成で tagged logging を安全に利用できます。
- 変更内容の詳細
※PR本文が短く、差分も小さいため、一般的な Rails の実装パターンおよび Ractor の制約に基づく技術的な読み解きになります。
背景: Ractor と logger / formatter
Ruby 3 の Ractor でオブジェクトを共有するには、そのオブジェクトが「Ractor-shareable」である必要があります。
基本的に以下のような制約があります:
- オブジェクトが freeze されていること
- 内部に保持しているインスタンス変数も Ractor-shareable であること
- freeze 済みオブジェクトに対して新たにインスタンス変数を書き換え・追加しないこと
ActiveSupport::TaggedLogging::Formatter は、通常の logger の formatter をラップしてタグ付きログを実現するクラスですが、従来の実装では内部でインスタンス変数を遅延初期化(メモ化)している部分が Ractor-shareable の観点で問題になりうる実装でした。
この PR で行われたこと
PR タイトル: Memoize ivar when freezing ActiveSupport::TaggedLogging::Formatter
意図としては次のような処理が追加されています:
ActiveSupport::TaggedLogging::Formatter#freezeが呼ばれたタイミング、もしくはその前に、- 内部で利用するインスタンス変数(例: タグの prefix 生成用の Proc/フォーマッタのラッパーなど)を「先に確定させて」メモ化する
- freeze 後に新規にインスタンス変数を設定しないようにする
イメージとしては下記のような変更が入っていると考えられます(擬似コード・実際のコードとは若干異なる可能性があります):
class ActiveSupport::TaggedLogging::Formatter
def initialize(formatter)
@formatter = formatter
end
# 以前はここで遅延初期化していた / 毎回ブロックを生成していたなど
def call(severity, time, progname, msg)
# tagged logging 用のラッパーを利用
# 例: @formatter.call(severity, time, progname, formatted_message)
end
def freeze
# freeze 前に必要な ivar を全て確定させる(遅延初期化しない)
some_helper # <= ここで @some_helper を初期化しておく、など
super
end
private
def some_helper
@some_helper ||= compute_helper_value
end
endポイントは:
freeze前に@some_helperなどのインスタンス変数を確定させておくことで、
freeze 後に@some_helper ||=のような遅延セットを実行しなくて済むようになる- これにより、Ractor から参照しても「freeze 済みオブジェクトに対するインスタンス変数の変更」が発生しないため、Ractor-shareable なオブジェクトとして扱えるようになる
テストの追加
activesupport/test/tagged_logging_test.rb にテストが6行追加されています。内容としては:
ActiveSupport::TaggedLogging::Formatterを freeze した状態で利用するケース- もしくは Ractor-shareable かどうか(
Ractor.shareable?)を確認するようなテスト
などが追加され、Ractor と組み合わせた利用でも例外が出ない・期待通りに動作することを確認しています。
- 影響範囲・注意点
- 対象:
ActiveSupport::TaggedLogging::Formatterを使ったログ出力(ActiveSupport::TaggedLogging経由の logger)- 特に、Ruby 3+ で Ractor を用いつつ、共通の logger / formatter を Ractor 間で共有したいケースで有効
- 従来コードへの互換性:
- 変更はインスタンス変数の初期化タイミング(遅延初期化 → freeze 前にメモ化)に関するものなので、
外部 API やログ出力フォーマットに影響が出る可能性はほとんどありません - Tagged logging の public インターフェース (
tagged { ... }など) は変更されていないはずです
- 変更はインスタンス変数の初期化タイミング(遅延初期化 → freeze 前にメモ化)に関するものなので、
- 注意点:
- Ractor 内で logger / formatter を共有する場合でも、「formatter 自体が freeze されていること」「内部で保持しているオブジェクト(例: underlying formatter)が shareable であること」は依然として必要です
- アプリ側で formatter を継承・モンキーパッチしていて、freeze 後にインスタンス変数を変更するようなコードがあると、Ractor 環境では引き続き問題になる可能性があります
- 参考情報 (あれば)
- Ruby 公式ドキュメント(Ractor と shareable オブジェクト)
- Rails ガイド: Active Support Tagged Logging
- 類似の変更(Ractor 対応のための freeze / shareable 対応)は Rails 7 系以降で他のクラスにも入っていますので、Ractor 対応を行う場合は logger 周り以外も含めて shareable 条件を確認することが推奨されます。
#57646 Minor Grammar Fixes
マージ日: 2026/6/10 | 作成者: @yashika279
概要 (1-2文で)
このPRは、Railsガイドの README (guides/README.md) に含まれていた英文の文法ミスを 1 箇所だけ修正したものです。アプリケーションコードやフレームワークの挙動には一切影響せず、ドキュメント品質の向上のみを目的としています。変更内容の詳細
- 対象ファイル:
guides/README.md - 変更行: +1 / -1(1 行分の文言を置き換え)
内容としては、英語の文章中の軽微な文法・表現の誤りを修正するものです。
具体的には、たとえば以下のようなタイプの修正に相当します(※イメージ例):
- This guides helps you to understand Rails.
+ These guides help you understand Rails.実際の PR でも同様に、
- 単数・複数形の不一致
- 冠詞 (a/the) の誤用
- 前置詞の選択ミス
- 動詞の形 (helps → help など)
といった「読んだときに違和感がある程度の軽微な文法ミス」を修正していると考えられます。
コードロジックの変更はなく、ガイド文書中の一文のみが差し替えられています。
- 影響範囲・注意点
- 影響範囲
- Rails の挙動、API、設定値、マイグレーション、生成物などには一切影響なし
- Rails ガイドを英語で読む際の可読性・自然さがわずかに向上
- 注意点
- バージョンアップ時に、この PR を理由にテストや動作確認を強化する必要はありません。
- ドキュメントの文言を引用している社内資料や記事がある場合、文言が 1 行だけ微妙に変わっている可能性はありますが、意味が変わるような修正ではないため、通常は気にしなくて構いません。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57646
- 対象ファイルの最新内容は、Rails 本家リポジトリの
guides/README.mdを参照してください。 - ドキュメント系 PR は、挙動には影響しないものの、ガイドやチュートリアルを教材として使う場合には誤解を減らす助けになります。
#57060 Offload ActiveStorage::Blob#metadata sync to background
マージ日: 2026/6/9 | 作成者: @shouichi
- 概要 (1-2文で)
Active Storage のActiveStorage::Blob#metadata更新処理を即時実行からバックグラウンドジョブ実行に切り離すことで、サーバーとワーカーが同時にメタデータを更新してしまうレースコンディション(特に GCS などでのエラー)を回避する変更です。これに伴い、サービス層の API と計測イベント(Instrumentation)がジョブ実行前提の形に整理されています。
- 変更内容の詳細
2-1. メタデータ同期のバックグラウンドジョブ化
新規ジョブが追加されています。
# activestorage/app/jobs/active_storage/sync_metadata_job.rb
class ActiveStorage::SyncMetadataJob < ActiveStorage::BaseJob
queue_as { ActiveStorage.queues[:analysis] }
def perform(blob)
blob.service.sync_metadata(blob.key, **blob.service_metadata)
end
endポイント:
ActiveStorage::Blobから「メタデータを同期する責務」を切り出し、SyncMetadataJobに委譲。- Queue は
ActiveStorage.queues[:analysis](既存の分析ジョブと同じ系統)に載せられる。 - 実際の同期処理は
Service#sync_metadataに委譲する形に統一。
2-2. ActiveStorage::Blob 側の変更
Blob モデルにおけるメタデータ同期の呼び出しが、即時実行からジョブのキュー投入に変更されています(実コードは概略レベルになりますが、イメージは以下のような形です)。
class ActiveStorage::Blob < ActiveRecord::Base
# 例: 以前は直接 service.update_metadata 的な呼出しを行っていた箇所
def sync_metadata_async
ActiveStorage::SyncMetadataJob.perform_later(self)
end
end- これまで「Blob 側で service を直接叩いてメタデータ更新」を行っていたパスが、原則としてバックグラウンドジョブを経由する作りに変わっています。
- テスト (
blob_test.rb,attachment_test.rb) もそれに合わせて、「呼び出しが即時にサービスに届く」前提から「ジョブを経由する」前提に修正されています。
2-3. ActiveStorage::Service API の整理
Service 基底クラスに「メタデータ同期」を表す明示的なメソッドが追加されています。
# activestorage/lib/active_storage/service.rb
class ActiveStorage::Service
# ...
# Blob 内に保持している metadata とサービス側のメタデータを同期するためのフック
def sync_metadata(key, **metadata)
# デフォルト実装は空実装 or 一般的な実装
end
end変更点:
- 以前は GCS サービスなどで独自にメタデータ更新メソッドを持っていたのを、共通の
sync_metadataインターフェースで扱うように統一。 - これにより、
SyncMetadataJobはサービスの種類を意識せず、常にblob.service.sync_metadataを呼ぶだけで済む。
2-4. GCS サービス (GCSService) の挙動変更
activestorage/lib/active_storage/service/gcs_service.rb が主な対象です。
主な変更ポイント:
- サーバープロセス(リクエスト処理)とワーカーが同時に GCS オブジェクトのメタデータを書き換えることで発生していた race condition を避けるよう、書き込みのタイミングを「ジョブからの
sync_metadata呼び出し」に集中させる。 - 以前はアップロードや添付の直後など、複数箇所からメタデータ更新を行っていたのを整理し、サービスレベルの更新窓口を
sync_metadataに限定するような形になっています。 - その過程で GCS の API 呼び出しパラメータやメタデータ更新ロジックが一部簡素化・再配置されています(+8/-10 行程度の差分)。
結果として、
- 「Blob レコードの書き換え」
- 「GCS へのメタデータ反映」
のタイミングが decouple され、同時実行での衝突が起きにくい設計になっています。
2-5. Active Support Instrumentation の更新
guides/source/active_support_instrumentation.md に Active Storage 関連イベントの説明追加・修正があります。
- メタデータ同期が非同期ジョブ経由になったことを反映したイベント説明が追加。
- 例えば「メタデータ同期開始/終了」イベントがどのタイミングで発火するか、payload に何が入るかといった説明がアップデートされています。
- これにより、監視やメトリクス収集で「いつメタデータ同期が走ったか」「どれぐらい時間がかかっているか」が追いやすくなります。
2-6. テスト・CHANGELOG の更新
activestorage/test/jobs/sync_metadata_job_test.rbで新ジョブの動作がテストされている- 指定された
Blobに対し、service.sync_metadataが正しく呼び出されるか - 複数回呼び出しや例外ハンドリングの基本的な挙動など
- 指定された
- 既存テスト (
blob_test.rb,attachment_test.rb) は、新しい非同期挙動を前提とした期待値に変更。 activestorage/CHANGELOG.mdに、- 「メタデータ同期がバックグラウンドで行われるようになった」
- 「レースコンディション回避」
などの変更点が追記されている。
- 影響範囲・注意点
3-1. メタデータの反映タイミングが「遅延」する
- 以前:
Blob#metadata更新に連動して(ほぼ)同期的にストレージサービスのメタデータも更新される前提だったコードがある場合、その前提は崩れます。 - 以後:
- メタデータ更新はジョブキューに積まれ、ワーカーが動いたタイミングで反映されます。
- 「更新直後にストレージ側のメタデータを前提とした処理」を行っていると、レースに負ける可能性があります。
対策・考慮:
- メタデータを元にしたバリデーションや外部連携処理がある場合、「DB 上の
Blob#metadataを信頼し、ストレージ側のメタデータ状態に依存しない」形に寄せるのが安全です。 - どうしてもストレージ側を即時に反映させたいケースがあるなら、独自ジョブや直接
service.sync_metadataを呼び出すラッパーをアプリ側で用意するなどの検討が必要です(ただし本 PR の意図である race 回避を阻害しないよう注意)。
3-2. Active Job / キュー基盤が必須となる度合い
- Active Storage 利用時に「メタデータ同期」が Active Job に依存するようになるため、バックグラウンドジョブが正しく動作しない環境では、メタデータ更新がストレージ側に反映されません。
- Rails 標準の
:asyncアダプタであっても最低限は動きますが、実運用では Sidekiq などのジョブランナーを安定運用する前提がより強くなります。
3-3. レースコンディションの修正による副作用
- GCS のように「同一オブジェクトへの並行メタデータ更新に厳しいサービス」での 409/412 系エラーは減る想定です。
- ただし、アプリ側で「サーバーからも独自にメタデータを更新する」ようなコードを追加してしまうと、再度同じ問題に近い状況を作りかねません。
→ ストレージサービスのメタデータ操作は、原則としてBlob標準機能 + このSyncMetadataJobに一本化するのが望ましいです。
3-4. モニタリング・ログ
- Instrumentation が更新されているため、
- メタデータ同期ジョブの開始/終了イベント
- 失敗時の例外情報
を購読することで、ジョブの詰まりやエラーを検知しやすくなります。
- 新しいイベント名や payload は
active_support_instrumentation.mdの更新を確認してログ/メトリクス設定を見直すとよいです。
- 参考情報 (あれば)
- 対応 Issue: https://github.com/rails/rails/issues/54919
- GCS などで Blob メタデータ更新時に発生していた race condition の詳細なレポートがあるはずです。
- PR 本体: https://github.com/rails/rails/pull/57060
activestorage/app/jobs/active_storage/sync_metadata_job.rbactivestorage/lib/active_storage/service.rbactivestorage/lib/active_storage/service/gcs_service.rb
を読むと、サービス API の揃え方とジョブ分離の具体的な意図が把握しやすいです。
- Active Storage ガイド:
- 今後のガイド更新で「分析やメタデータ関連処理はジョブベース」前提がより明示されていく可能性があります。
#57639 [ActiveModel] Re-run normalizes only when the value changed in place
マージ日: 2026/6/10 | 作成者: @yaroslav
- 概要 (1-2文で)
ActiveModel.normalizesを使った属性正規化について、「未保存レコードでvalid?を呼ぶたびに毎回正規化が再実行されてしまう」不具合を修正し、本当に「インプレース変更」があった場合だけ再正規化するようにした PR です。これにより、挙動のバグが直るとともに、特に未保存レコードの繰り返しバリデーション時のパフォーマンスが改善されます。
- 変更内容の詳細
問題の背景
normalizes :name, with: ->(value) { ... } のような正規化は、属性の値が「インプレースで変更されたとき」に再度走るように設計されています。その判定には attribute_changed_in_place? が使われていました。
しかし:
- 未保存レコードには「保存済みの値」がなく、内部的には
nilと比較される - そのため「一度読んだだけの属性」でも「保存済み値(nil) と違う」と判定されてしまう
- 結果として、未保存レコードに対して
valid?を呼ぶたびに、正規化が毎回実行される という挙動になっていました
これは特に「再実行するたびに結果が変わる正規化」においてバグになります:
class User
include ActiveModel::Model
include ActiveModel::Attributes
attribute :code, :string
normalizes :code, with: ->(v) { v&.succ } # 文字列を次の文字に
end
user = User.new(code: "b")
user.valid? # "b" -> "c"
user.valid? # "c" -> "d"
user.valid? # "d" -> "e"意図としては「インプレース変更がない限り、同じ値を繰り返し正規化すべきではない」ので、これは不正な挙動です。
新しい判定ロジック
この PR では、「本当に値がインプレース変更されたかどうか」を、次のように判定するように変更しています:
- 「元の値 (original value)」を覚えておく
- 現在の値を、元の値に正規化をかけた結果 と比較する
- もし「現在値 = 元の値を正規化したもの」と等しければ
→ 読み出しただけで変更されていないとみなし、元の値にリセットする
→ その後のvalid?では再正規化しない - もし「現在値 ≠ 元の値を正規化したもの」であれば
→ インプレース変更があったとみなし、もう一度正規化を走らせる
- もし「現在値 = 元の値を正規化したもの」と等しければ
このアプローチにより:
- 単なる読み取り
- 一度正規化された後、未保存のまま
valid?を何度呼んでも、余計な再正規化は走らない - 内部では元の値に戻されるので、「次に読むときに再度正規化」が正しく行われる
- 一度正規化された後、未保存のまま
- インプレース変更 (
user.name.strip!など)- 「元の値を正規化したもの」と今の値が食い違うため、再度正規化が走る
結果として、「本当にインプレース変更があったときだけ再正規化」が実現されます。
既存 API 観点での動作イメージ
通常のパターン:
class User
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
normalizes :email, with: ->(value) { value&.strip&.downcase }
end
user = User.new(email: " Foo@Example.COM ")
user.valid?
# email は "foo@example.com" に一度だけ正規化
user.valid?
# 2回目以降の valid? では、正規化は再実行されない (今回の修正)インプレース変更パターン(例):
user.email # => "foo@example.com"
user.email.upcase! # インプレース変更
user.valid?
# upcase! の結果に対して、正規化 (strip/downcase) が再度実行される
# => "foo@example.com" に戻る今回の修正により、上の挙動が正常に担保されるようになります。
実装上の主な変更
activemodel/lib/active_model/attributes/normalization.rbnormalize_changed_in_place_attributesのロジックを修正- 変更の有無判定を「保存済み値との比較」から、「元値を再正規化した結果との比較」に変更
- 「単に読まれただけ」のケースでは元の値に戻す挙動を追加
activemodel/test/cases/attributes/normalization_test.rb- 上記の新しい挙動を検証するテストを追加
activemodel/CHANGELOG.md- バグ修正として記載を追加
- 影響範囲・注意点
影響範囲
対象:
ActiveModel::Attributesのnormalizes機能を使っているモデル- 特に「未保存レコード」に対して
- 属性を読み出し
valid?を何度も呼び出す- もしくはインプレース変更を行う というパターンがある場合
期待されるメリット:
- 再実行で結果が変わる正規化(
succ, 増分・ランダム文字列付与など)のバグが解消 - 再実行で結果が変わらない正規化(
strip,downcase,squishなど)でも無駄な計算が減る - ベンチマークでは未保存レコードのバリデーションで若干の性能改善:
- YJIT off: 480k i/s → 533k i/s (~1.1x)
- YJIT on: 1.10M i/s → 1.13M i/s (ほぼ同等)
- 1回の
valid?あたりの割り当て数は変化なし (6 → 6)
- 再実行で結果が変わる正規化(
挙動変更として意識すべき点
- 「
valid?のたびに正規化ロジックが走る」ことを前提にしたコードは壊れる可能性があります- 本来の設計としては「インプレース変更時のみ再正規化」が正しいため、多くの場合はバグ依存であり、この修正は望ましい互換性変更と考えられます。
- 値の「元の状態」に戻すロジックが入ったことで、デバッガで「今メモリ上にある値」を目視すると、少し挙動がわかりにくくなる場面があるかもしれませんが、公開 API 的には整合的な挙動です。
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/57639
- 関連 PR: https://github.com/rails/rails/pull/57619
- 対象機能:
ActiveModel::Attributesのnormalizes - 変更対象ファイル:
activemodel/lib/active_model/attributes/normalization.rbactivemodel/test/cases/attributes/normalization_test.rbactivemodel/CHANGELOG.md
#57630 Remove unordered baseline assertions from test_default_order
マージ日: 2026/6/9 | 作成者: @yahonda
- 概要 (1-2文で)
default_orderに関するテストのうち、ORDER BY句がないクエリに対して戻り順を前提にしていた不安定なアサーションを削除し、DB依存でランダムに落ちるテストを安定化する変更です。default_order機能自体の挙動は変えず、テストのみを整理しています。
- 変更内容の詳細
問題となっていたテスト
失敗していたのは HasManyAssociationsTest#test_default_order で、以下のように plain な has_many をそのまま pluck していました:
comments = posts(:welcome).comments
assert_equal [1, 2], comments.pluck(:id)しかし comments には order 指定がなく、生成される SQL は PostgreSQL では次のようになります:
SELECT "comments"."id" FROM "comments" WHERE "comments"."post_id" = $1ORDER BY が無いため、返ってくるレコードの順序は 未定義 であり、本来 [1, 2] である保証はありません。
たまたま今までは [1, 2] になっていただけで、ビルド環境やDBの状態によって [2, 1] になることがあり、実際に rails-nightly CI で [2, 1] が返ってテストが落ちました。
同様の「順序未定義な baseline アサーション」が RelationTest#test_default_order にも存在しており、こちらも潜在的に同じ問題を抱えていました。
実際の修正内容
対象ファイルは次の2つです。
activerecord/test/cases/associations/has_many_associations_test.rbactiverecord/test/cases/relations_test.rb
いずれも「default_order を使っていない、順序未定義なクエリに対するアサーション行」を削除しています。
具体的には:
HasManyAssociationsTest#test_default_orderから、posts(:welcome).comments.pluck(:id)の結果が[1, 2]である、というアサーションを削除
RelationTest#test_default_orderから、- 同様に単純な Relation に対して「この順序で返るはず」というアサーションを削除
PR本文にもある通り、.sort を付けて
assert_equal [1, 2], comments.pluck(:id).sortのようにすれば安定化はできますが、その場合に確認できるのは「コメントID 1と2が返ってくる」ことだけであり、これは他のテストでも既にカバーされています。
一方で、default_order の有無・挙動自体はこれらの baseline アサーションとは無関係なので、テスト意図に対してノイズとなるため、削除という判断になっています。
- 影響範囲・注意点
- ランタイムの挙動:
- Active Record の
default_order機能や関連の実装には一切変更がなく、アプリケーションコードへの影響はありません。
- Active Record の
- テスト:
- rails 本体のテストスイートのみが対象で、DBのレコード返却順序に依存してランダムに落ちる要因が取り除かれます。
- 今回削除された箇所は、
default_orderを直接検証しているわけではなく、「たまたま順序がそうなっていること」に依存した baseline であり、削除してもdefault_order自体のカバレッジには実質的な影響はありません。
- 注意点(自分のプロジェクトへの示唆):
- Rails 本体のこの修正はテストだけですが、アプリ側のテストでも同様に「
ORDER BYなしのクエリ結果の順序を前提にしたアサーション」を書いていないか注意した方がよいです。- もし順序を前提にしたいなら
order(...)を指定する - 単に要素が含まれていることだけを確認したいなら
sortやmatch_arrayを使う
- もし順序を前提にしたいなら
- 特に PostgreSQL や MySQL で
ORDER BYがない SELECT の戻り順は仕様上保証されないので、同様の flaky テストの温床になります。
- Rails 本体のこの修正はテストだけですが、アプリ側のテストでも同様に「
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/57630
default_orderが追加された PR: https://github.com/rails/rails/pull/39525- rails-nightly 該当失敗ログ:
https://buildkite.com/rails/rails-nightly/builds/4408#019ea909-5baa-430c-91a2-66d4fe1f65ee/L11949
#57636 Fix number_to_currency crashing on a negative number with precision: nil
マージ日: 2026/6/9 | 作成者: @55728
- 概要 (1-2文で)
number_to_currencyにprecision: nil(丸めなし)を渡したとき、負の数だけがTypeErrorでクラッシュしていたバグを修正した PR です。負数でも正数と同様にprecision: nilを受け付け、クラッシュせずに通貨文字列を返すようになります。
- 変更内容の詳細
問題の挙動
number_to_currency は本来、precision: nil を指定すると「丸めを行わず、そのまま表示する」という意味になりますが、負数に対してのみ例外が発生していました。
number_to_currency(1234.5678, precision: nil)
# => "$1,234.5678" # OK
number_to_currency(-1234.5678, precision: nil)
# => TypeError: nil can't be coerced into Integer
number_to_currency("-1234.5678", precision: nil)
# => TypeError: nil can't be coerced into Integer一方で、同系のヘルパーは負数でも問題ありません。
number_to_rounded(-1234.5678, precision: nil)
# => "-1234.5678"
number_to_percentage(-1234.5678, precision: nil)
# => "-1234.5678%"バグの原因
NumberToCurrencyConverter#convert の負数側の処理に、「マイナスゼロ(-$0)を表示しないためのガード」が入っています。
format = options[:negative_format] if (number_d * 10 ** options[:precision]) >= 0.5- 趣旨: 丸めた結果が 0 になるような非常に小さい負数 (
-0.0001など) を、-$0と表示しないようにするための判定。 - 問題:
precision: nilのとき、10 ** options[:precision]→10 ** nilとなりTypeErrorが発生する。 - 正数側の分岐ではこのガードが実行されないため、「負数のときだけ落ちる」という挙動になっていた、という構造です。
このガードは過去のコミット ce321c4539(「非常に大きな数値を扱う対応」)で追加されたもので、その際に precision: nil ケースが考慮されていませんでした。
修正内容
「precision: nil のときはそもそも丸めをしない」ことから、
- 非ゼロの負数は常に負の符号を維持する
- よって「丸めたら 0 かどうか」の判定自体が不要
と判断し、ガード条件に options[:precision].nil? のチェックを追加しています。
修正前:
format = options[:negative_format] if (number_d * 10 ** options[:precision]) >= 0.5修正後:
format = options[:negative_format] if options[:precision].nil? || (number_d * 10 ** options[:precision]) >= 0.5これにより:
precisionがnilの場合は(number_d * 10 ** options[:precision])を評価せずにnegative_formatを適用precisionが数値の場合は従来どおり「丸めたら 0 以上か?」のチェックを行う
という分岐になり、TypeError が解消されます。
テスト
activesupport/test/number_helper_test.rb に回帰テストが追加されています。
新しいテストは、負の数および負の数値文字列に対して precision: nil を渡したときに例外が起きず、期待する形式で出力されることを検証しています。
- 影響範囲・注意点
影響範囲
ActionView::Helpers::NumberHelper#number_to_currencyをprecision: nil付きで使っているコードで、負数が入力されるケースに影響します。- これまで負数で
precision: nilを指定するとTypeErrorが発生していた箇所は、正常に通貨文字列が返るようになります。 - 正数、および
precisionが数値の場合の動作は変更されません。
互換性
- バグ修正であり、仕様の後方互換性は基本的に維持されています。
- もしアプリ側でこの
TypeErrorを前提にした独自ハンドリングをしていた場合は、そのハンドリングが呼ばれなくなる可能性がありますが、通常は望ましい改善と考えられます。
マイナスゼロ (
-$0問題) への影響precisionが数値のときのガードロジックは従来どおり残っているので、「ごく小さい負数が丸め後に 0 になるケース」を-$0と表示してしまう問題は引き続き回避されます。precision: nilのときはそもそも丸めをしないため「丸め後 0 かどうか」の議論が不要であり、挙動は合理的です。
- 参考情報 (あれば)
- 対象メソッド:
ActionView::Helpers::NumberHelper#number_to_currency - 実装箇所:
activesupport/lib/active_support/number_helper/number_to_currency_converter.rb
- 関連ヘルパー(挙動比較用):
#number_to_rounded#number_to_percentage
#57635 Preserve the sub-second fraction in TimeZone#strptime with %s
マージ日: 2026/6/9 | 作成者: @55728
- 概要 (1-2文で)
ActiveSupport::TimeZone#strptimeで、"%s.%N"/"%s.%L"のようなエポック秒+小数形式をパースした際にサブ秒(ナノ秒)が失われていたバグを修正したPRです。Ruby標準ライブラリTime.strptimeと同じ挙動になるよう、小数部分も含めてTimeを生成するようにしています。
- 変更内容の詳細
問題となっていた挙動
ActiveSupport::TimeZone#strptime でエポック秒形式 (%s) を使うと、サブ秒が存在しても無視されていました:
zone = ActiveSupport::TimeZone["UTC"]
zone.strptime("1577836800.123456789", "%s.%N").nsec
# => 0 # サブ秒が欠落
Time.strptime("1577836800.123456789", "%s.%N").nsec
# => 123456789 # Ruby 標準ライブラリは正しく保持原因は、内部で DateTime._strptime が返す parts のうち、
{
seconds: 1577836800,
sec_fraction: (123456789/1000000000r)
}のように :seconds と :sec_fraction の両方があるケースで、ActiveSupport::TimeZone 側の parts_to_time ヘルパーが :seconds ブランチで :sec_fraction を無視していたことにあります。
擬似コード的には以下のような状態でした:
if parts[:seconds]
# バグ: 小数部分を捨てていた
time = Time.at(parts[:seconds]) # sec_fraction を足していない
else
# こちらの分岐では sec_fraction を考慮している
time = Time.new(
parts[:year],
parts[:mon],
parts[:mday],
parts.fetch(:hour, 0),
parts.fetch(:min, 0),
parts.fetch(:sec, 0) + parts.fetch(:sec_fraction, 0),
utc_offset
)
end一方で、iso8601 / rfc3339 / parse など、_strptime が :seconds ではなく :sec + :sec_fraction を使う経路では、もともとナノ秒が保持されていました。そのため、%s だけが不自然にサブ秒を捨てる状態になっていました。
修正内容
parts[:seconds] が存在する分岐でも :sec_fraction を加算して Time を生成するように1行修正しています:
# 修正前
time = Time.at(parts[:seconds])
# 修正後
time = Time.at(parts[:seconds] + parts.fetch(:sec_fraction, 0))これにより、
zone = ActiveSupport::TimeZone["UTC"]
zone.strptime("1577836800.123456789", "%s.%N").nsec
# => 123456789と、Ruby標準 Time.strptime と同じ結果になります。
テスト
以下のようなテストが追加されています(要旨):
def test_strptime_with_timestamp_seconds_and_fractional_seconds
zone = ActiveSupport::TimeZone["UTC"]
time = zone.strptime("1577836800.123456789", "%s.%N")
assert_equal 1577836800, time.to_i
assert_equal 123456789, time.nsec
end既存の %s (整数秒のみ) や %Q (ミリ秒) に関するテストは影響を受けず、time_zone_test.rb 全体がグリーンであることが確認されています。
- 影響範囲・注意点
影響対象:
ActiveSupport::TimeZone#strptimeを%s.%N/%s.%Lのような「エポック秒 + 小数」の形式で使っているコード。- それ以外の形式 (
%Y-%m-%d, ISO8601, RFC3339 など) や、Time.strptime(Ruby標準) には影響なし。
互換性:
- 以前はサブ秒が 0 に丸められていたところが、正しいサブ秒を保持するようになります。
- もし既存コードが「
nsecは常に 0 である」という前提に依存していた場合、その前提は崩れますが、これはバグ修正として妥当な互換性変更です。 %s単体 (サブ秒なし) のケースは、sec_fraction自体が存在しないため挙動は変わりません。%Q(ミリ秒) は_strptime側で最初から:secondsをRationalで返すため、以前からサブ秒が保持されており、今回の変更でも挙動は変わりません。
パフォーマンス:
Time.atにRational/Float相当を渡している形になるだけで、実質的なオーバーヘッドは軽微と考えられます。
実運用上の注意:
- 高精度なタイムスタンプ(ログや分散トレーシングのタイムスタンプなど)を
TimeZone#strptime+%s.%Nで取り扱っている場合、これまでは精度が失われていたことになります。- これを前提にしたロジック (例: 「同じ秒の中ではすべて同時刻として扱う」など) がある場合、挙動が変化する可能性があります。
- Ruby標準とActiveSupportの挙動差を前提にしたワークアラウンドがある場合は、不要になるため削除できる可能性があります。
- 高精度なタイムスタンプ(ログや分散トレーシングのタイムスタンプなど)を
- 参考情報 (あれば)
関連API:
ActiveSupport::TimeZone#strptimeTime.strptime(Ruby標準)DateTime._strptime(内部で使用される解析ルーチン)
挙動の整理:
- ISO8601/RFC3339、
Time.zone.parse等: もともとサブ秒保持済み。 %s(整数エポック秒): 以前から正しく秒単位で扱い、サブ秒なし。%Q(ミリ秒):_strptime側で:secondsにRationalを返すため、もともとサブ秒保持済み。%s.%N/%s.%L: 今回の修正により、Ruby標準と同等にサブ秒を正しく保持するようになった。
- ISO8601/RFC3339、
#57634 Forward blocks to DelegateClass methods that yield implicitly
マージ日: 2026/6/9 | 作成者: @55728
- 概要 (1-2文で)
ActiveSupport::Delegation.DelegateClassが、暗黙にyieldするメソッドへブロックを正しく委譲できていなかったバグを修正し、Ruby標準ライブラリのDelegateClassと同じくブロックを転送するようにしたPRです。これにより、scanやmapなどブロック必須のメソッドをラップしても、期待どおりブロックが動作するようになります。
- 変更内容の詳細
問題の背景
ActiveSupport::Delegation.DelegateClass は、ラップ対象クラスの public メソッドを走査し、それぞれに対応する delegator メソッドを動的に定義しています。このとき、Ruby の Method#parameters からメソッドシグネチャ(引数リスト)を組み立てる実装に変えた結果、以下のような問題が出ていました。
- 「暗黙のブロック」を使うメソッド(
def foo; yield; endのように&blockを引数に取らず、内部でyieldするメソッド)はparametersに:block情報を持たない - そのため、生成される delegator メソッドのシグネチャには
&blockが含まれない - 結果として、呼び出し側でブロックを渡しても、委譲先メソッドにはブロックが渡らず ブロックが黙って捨てられる
例:
wrapper = ActiveSupport::Delegation.DelegateClass(String).new("a1b2c3")
res = []
wrapper.scan(/\d/) { |m| res << m }
res
# 実際: [] (ブロックが実行されない)
# 期待: ["1", "2", "3"] (Ruby stdlib DelegateClass の挙動)同様に Array#map のような典型的なブロックメソッドも壊れていました:
ActiveSupport::Delegation.DelegateClass(Array).new([1, 2, 3]).map { |n| n * 2 }
# 実際: [1, 2, 3] (ブロック無視)
# 期待: [2, 4, 6]Ruby 標準の DelegateClass は暗黙ブロックも含めて正しく転送するため、Rails 側の DelegateClass が意図した仕様から外れていた形です。
修正方針
- すでに
...(引数をすべて転送するシンタックス)を使うパスではブロックも自動転送されており問題なし - 問題があるのは「
parametersから明示的に引数リストを組み立てているパス」で、ここに 匿名ブロック転送&を足す ことで対応
Ruby では、メソッド定義側で & だけを書くと「呼び出し時に渡されたブロック(あるいは nil)をそのまま転送する」ことができます。これを利用して:
- 元から
&blockを引数に持つメソッドは今までどおり(既に block param があるのでそのまま) parameters上はブロック引数を持たないメソッドでも、定義側で匿名&を付けることで- ブロックあり呼び出し → ブロックを委譲先に渡す
- ブロックなし呼び出し →
nilブロックが渡る(挙動は従来と同じ)
という挙動を実現しています。
実際にどう変わるか
修正後:
wrapper = ActiveSupport::Delegation.DelegateClass(String).new("a1b2c3")
res = []
wrapper.scan(/\d/) { |m| res << m }
res
# => ["1", "2", "3"] # Ruby stdlib の DelegateClass と同じ
ActiveSupport::Delegation.DelegateClass(Array).new([1, 2, 3]).map { |n| n * 2 }
# => [2, 4, 6]テスト・実装の変更点
activesupport/lib/active_support/delegation.rbDelegateClassの delegator 生成部分に匿名&を付与する形で約 5 行変更...を使うパスはもともとブロック転送されているため変更なし
activesupport/test/delegation_test.rb- 新規ファイルとして
DelegateClassの挙動テストを追加(+62 行) - この PR の修正がない状態だと 6 テスト中 5 つが fail/error し、修正によりすべてパス
- 新規ファイルとして
- CHANGELOG にエントリ追加(ユーザー可視の挙動修正として明示)
- 影響範囲・注意点
- 影響対象:
ActiveSupport::Delegation.DelegateClassを直接使っているコード- それを内部的に使っている Rails の各種デコレータ(例: ActiveRecord の型メタデータ、楽観的ロック周りなど)
- 具体的には、委譲先のメソッドが暗黙
yieldを使っている場合 に挙動が変わります- これまで「ブロックが完全に無視されていた」ケースで、今回からは「正しくブロックが実行される」ようになる
- つまり、意図せず「ブロックが無視されていたバグ」が表面化する可能性はあります(本来の動作に戻るという意味での互換性差分)
- ブロックを渡さない呼び出しへの影響はありません
- 匿名
&はブロックがない場合nilを転送するだけなので、委譲先の挙動は変わりません
- 匿名
- 標準ライブラリの
DelegateClassと挙動が揃うため、stdlib ベースの実装から Rails のDelegateClassに乗り換えている場合などは、むしろ期待どおりになります。
利用者側での確認ポイント:
DelegateClassを使って定義した wrapper / decorator クラスでeach/map/scan/times/findなど「ブロックを前提とするメソッド」を委譲している箇所がないか- もし以前から「ブロックが呼ばれていない?」などの違和感があった場合、今回の修正で問題が解消していないか確認する価値があります
- 参考情報 (あれば)
- Ruby 標準ライブラリ
DelegateClassドキュメント:
https://docs.ruby-lang.org/en/master/DelegateClass.html - 関連する変更(前提となった最適化):
- コミット
dc17084b7b"Faster class delegator" —parametersベースでシグネチャを生成するようにした変更
- コミット
- Ruby のブロック転送挙動について:
def foo(&block)… block を明示的に受け取るdef foo(...)… 引数・キーワード・ブロックをすべてそのまま転送def foo(*)+bar(&)のような匿名&構文で「受け取ったブロックをそのまま転送」できる(今回利用されているテクニックに相当)
#57632 Carry has_many default_order: into the cached association SQL
マージ日: 2026/6/9 | 作成者: @55728
- 概要 (1-2文で)
has_manyの新オプションdefault_order:が、関連先レコードを実際にロードするときに無視されてしまう不具合を修正し、かつ statement cache を維持したままORDER BYをキャッシュされた SQL に反映するようにした PR です。default_order:を指定した関連でも、to_a/reloadなどでのロード結果が常に期待どおりの順序になるようになります。
- 変更内容の詳細
問題の挙動
has_many に default_order: を付けた場合、本来は関連取得時のデフォルトの ORDER BY を指定できますが、以下のようなギャップがありました:
class Author < ApplicationRecord
has_many :posts, default_order: "posts.id DESC"
end
author = Author.first
author.posts.to_sql # => "... ORDER BY posts.id DESC" ✅ SQL上は並び替えがある
author.posts.pluck(:id) # => [6, 5, 4, 2, 1] ✅ 期待どおり DESC
author.posts.to_a.map(&:id) # => [1, 2, 4, 5, 6] ❌ DBの素の順序
author.posts.map(&:id) # => [1, 2, 4, 5, 6] ❌to_sqlやpluckなど「Relation をそのまま使う経路」ではORDER BYが効いている。- しかし、関連のターゲット(
author.postsの実体の配列)をロードする経路 (to_a,map,reloadなど) ではdefault_order:が反映されておらず、DB 任せの順序になっていた。
結果として「SQL には ORDER BY が出ているのに、実際のオブジェクト配列は別の順序」という、非常にわかりにくい silent wrong-result が起きていました。
原因
CollectionAssociation#scope- ここでは
scope.default_order!(options[:default_order])が呼ばれるため、pluck/count/to_sqlなど「関連スコープとして Relation を組み立てるとき」にはdefault_order:が反映される。
- ここでは
Association#find_target(関連先のロード)- 通常は statement cache を使う「高速パス」で SQL を組み立てる。
- この高速パスでは
AssociationScope#scopeを使って SQL が生成されるが、ここにはoptions[:default_order]を反映する処理がなかった。 - 一方で、
skip_statement_cache?がtrueの「遅いパス」ではself.scopeを使うためdefault_order:が効くが、今回のケース(シンプルなhas_many+default_order:のみ)だとskip_statement_cache?はfalseとなり、常に高速パスが使われる。その結果、ターゲットのロード時にはORDER BYなしのキャッシュ SQL が使われていた。
つまり、「relation として扱うときは CollectionAssociation#scope 経由で default_order が効くが、ターゲットロード時の高速パスは AssociationScope#scope 経由で、そこには default_order がなかった」という抜け漏れです。
修正内容
以前の PR (#57538) では、「default_order: が指定された関連は statement cache を使わない(= skip する)」という方向で修正していましたが、この PR ではそれをやめて、
AssociationScope#scope自体にdefault_order:を反映させ、キャッシュされる SQL にORDER BYを含める
というアプローチに変更しています。
具体的な変更:
# activerecord/lib/active_record/associations/association_scope.rb
scope.default_order!(reflection.options[:default_order]) if reflection.options[:default_order].present?ポイント:
reflection.options[:default_order]を参照し、AssociationScope#scopeでdefault_order!を呼び出すことで、statement cache 用のスコープにもORDER BYを組み込む。default_orderはNORMAL_VALUESな merge key:target_scope.merge!の過程でこの値がきちんと引き継がれる。
default_order!は「追加」ではなく「代入」挙動:- 既存の
CollectionAssociation#scopeでのdefault_order!呼び出しと衝突せず、結果として idempotent になる(同じ default_order が二重に乗るようなことが起きない)。
- 既存の
この結果、statement cache を無効化せずに、キャッシュされる SQL 自体に ORDER BY を含められるようになりました。
テスト追加
has_many_associations_test.rb および author モデルのテスト定義に以下を追加:
# test/models/author.rb
has_many :posts_with_default_order, class_name: "Post", default_order: "posts.id DESC"テスト内容:
test_default_order_is_applied_when_the_target_is_loadedto_a/reloadしたときの実際のレコード順がdefault_order:による SQL の順と一致することを検証。
test_default_order_keeps_using_the_statement_cacheskip_statement_cache?がfalseのままであることを検証し、「statement cache を捨てた結果 order が効いている」のではなく、「キャッシュ SQL に order を埋め込んでいる」ことを確認。
テストは sqlite3 / postgresql / mysql2 / trilogy で has_many_associations_test がグリーンになることを確認済みです。
- 影響範囲・注意点
- 新機能
has_many ... default_order:(まだ未リリース)の挙動が修正されます。- これまで(不具合発生中)は、
pluckなどだけが意図どおり並び替えられ、関連ロード(to_a,reload,mapなど)は DB 任せの順序でした。 - この PR 以降は、「関連をロードするときも、
default_order:が常に尊重される」ようになります。
- これまで(不具合発生中)は、
- statement cache の利用は維持されるため、
default_order:を使ったからといってパフォーマンス劣化(毎回 SQL を組み立て直す等)は起こらない想定です。 default_order:は「デフォルトの順序」を決めるためのものであり、明示的に.order(...)を指定した場合はそちらが優先される設計(通常の order と同様)であることが想定されます。default_scopeやスコープブロックを併用している関連の場合も、NORMAL_VALUESのマージルールのもとで適切に作用するように意図されていますが、複雑なスコープ合成をしているプロジェクトでは挙動確認をしておくと安心です。
- 参考情報 (あれば)
- 対応するコメント: https://github.com/rails/rails/pull/57538#issuecomment-4655646072
- 置き換えられた PR: #57538
default_order:を導入したコミット:aa76590371(2026-05-28 マージ、まだリリース前)
#57633 Preserve falsy default value when building HashWithIndifferentAccess
マージ日: 2026/6/9 | 作成者: @55728
- 概要 (1-2文で)
ActiveSupport::HashWithIndifferentAccess.newに元ハッシュを渡したとき、元ハッシュにfalseをデフォルト値として設定していても、それが失われずにそのまま引き継がれるようにするバグ修正です。これにより、Hash.new(false)など「未知キーは false を返す」ことを前提にしたコードが、HWIA 変換後も期待通り動作します。
- 変更内容の詳細
これまでの挙動
HashWithIndifferentAccess.new(hash) は、元ハッシュのデフォルト値をコピーする処理を持っていますが、その条件分岐が以下のようになっていました:
self.default = hash.default if hash.defaultRuby では false と nil が「偽」なので、元ハッシュが Hash.new(false) で作られている場合でも、hash.default が false だとこの条件が発火せず、デフォルト値がコピーされませんでした。
その結果:
source = Hash.new(false)
source["a"] = 1
hwia = ActiveSupport::HashWithIndifferentAccess.new(source)
hwia.default # => nil (本来は false であってほしい)
hwia["missing"] # => nil (本来は false であってほしい)元の Hash と HashWithIndifferentAccess で、存在しないキーアクセス時の挙動が食い違うバグになっていました。
修正内容
条件を「nil かどうか」で判定するように変更しています:
# 修正前
self.default = hash.default if hash.default
# 修正後
self.default = hash.default unless hash.default.nil?意味としては:
defaultがnil(通常の Hash のデフォルト)ならコピーしない(従来と同じ・多くのケースで no-op)defaultがfalseやその他の値の場合は、そのままコピーして HWIA 側のdefaultに設定する
これにより、以下のように期待通りの挙動になります:
source = Hash.new(false)
source["a"] = 1
hwia = ActiveSupport::HashWithIndifferentAccess.new(source)
hwia.default # => false
hwia["missing"] # => false
hwia[:missing] # => false # indifferent access でも同様テスト
activesupport/test/hash_with_indifferent_access_test.rbに回 regresson テストを追加- 「
Hash.new(false)を渡したときに、デフォルトが失われない」ことを確認 - 既存の HashWithIndifferentAccess テストスイート (96 テスト) はすべて green
- 影響範囲・注意点
- 影響範囲:
ActiveSupport::HashWithIndifferentAccess.new(some_hash)を使っており、かつsome_hashがHash.new(false)など、nil 以外のデフォルトを設定しているケース
- これまで:
- そうしたコードでは「元ハッシュでは unknown key → false だが、HWIA に変換すると unknown key → nil になる」という微妙な挙動差がありました。
- 今後:
- 変換後も元ハッシュのデフォルト値が忠実に引き継がれ、
HashとHashWithIndifferentAccess間で missing key 挙動がより一貫します。
- 変換後も元ハッシュのデフォルト値が忠実に引き継がれ、
- 互換性:
- 挙動が変わるのは「元デフォルトが
nil ではない」場合だけで、基本的にはバグ修正として望ましい方向です。 - もし「HWIA に変換するとデフォルトが
nilになる」ことに依存していたコードがあれば、今回の修正でfalse(など元デフォルト)を返すようになります。そのようなコードがあるとテストが落ちる可能性がありますが、元仕様に沿う形での修正と考えられます。
- 挙動が変わるのは「元デフォルトが
- パフォーマンス:
hash.default.nil?チェックはごく僅かなオーバーヘッドで、実質的な影響は無視できる範囲です。
- 参考情報 (あれば)
- この挙動は、
HashWithIndifferentAccess.newを.new_from_hash_copying_defaultと揃えるための以前の変更(コミット6e574e8a11)の副作用として現れたものを修正する位置づけです。 - Ruby 標準
Hashのdefault:Hash.new→defaultはnilHash.new(false)→defaultはfalse- また、
hash.default_procがある場合との関係はこの PR では触れていません(defaultがnilかどうかだけを見ている)。
#57606 Resolve attribute aliases for the locking column in update_all
マージ日: 2026/6/9 | 作成者: @55728
- 概要 (1-2文で)
update_allで楽観ロック用カラム(lock_version)をalias_attribute経由で指定したときに、値が失われたり PostgreSQL でエラーになる不具合を修正する PRです。update_all内でロックカラムを検出する際に、属性エイリアスも正しく解決して判定するように変更されています。
- 変更内容の詳細
問題の挙動
モデル側で lock_version にエイリアスを貼っている場合:
class Widget < ActiveRecord::Base
alias_attribute :v, :lock_version
end
Widget.where(id: id).update_all(v: 10)- SQLite/MySQL:
lock_versionが10ではなく1(自動インクリメント値)になり、ユーザー指定値が黙って失われる - PostgreSQL:
PG::SyntaxError: multiple assignments to same column "lock_version"でエラー
一方、エイリアスを使わずにカラム名そのもので指定した場合は問題なし:
Widget.where(id: id).update_all(lock_version: 10)
# => lock_version が正常に 10 になる原因
update_all は「楽観ロック有効かつ、呼び出し側が lock_version を更新していない場合」に、自動的にロックカラムをインクリメントする処理を持っています。
元コード(簡略化):
if model.locking_enabled? &&
!updates.key?(model.locking_column) &&
!updates.key?(model.locking_column.to_sym)
attr = table[model.locking_column]
updates[attr.name] = _increment_attribute(attr)
end
values = _substitute_values(updates)ここで行っているチェックは キー文字列/シンボルが lock_version かどうかだけ です。そのため:
alias_attribute :v, :lock_versionの場合、updatesには{ "v" => 10 }だけが入っているupdates.key?("lock_version")もupdates.key?(:lock_version)もfalse- 「ロックカラムが指定されていない」と誤判定され、自動インクリメントが追加される
その後 _substitute_values が、v も追加された lock_version も両方とも物理カラム lock_version にマッピングして SQL を生成するため、次のような二重代入になる:
UPDATE "widgets"
SET "lock_version" = 10,
"lock_version" = COALESCE("widgets"."lock_version", 0) + 1
WHERE ...DBごとの挙動:
- SQLite/MySQL: 同一カラムへの複数代入では「最後の値が勝つ」ため、自動インクリメント側が適用され、ユーザーの指定値は捨てられる
- PostgreSQL: 同一カラムへの複数代入をエラーとして扱うため、
PG::SyntaxErrorが発生
修正内容
更新キーごとに model.attribute_alias を通し、エイリアスを解決した上でロックカラムかどうかを判定するように変更:
変更後のロジック(要旨):
if model.locking_enabled? &&
updates.keys.none? { |key|
(model.attribute_alias(key) || key.to_s) == model.locking_column
}
attr = table[model.locking_column]
updates[attr.name] = _increment_attribute(attr)
endポイント:
updatesの各キーkeyに対して:model.attribute_alias(key)でエイリアスを解決し(例:"v"→"lock_version")- 解決結果が
nilならkey.to_sをそのまま使う - それが
model.locking_column(通常"lock_version")と一致するかを調べる
- 1つでもロックカラムにマッピングされるキーがあれば、自動インクリメントは行わない
- 何もマッピングされなければ、従来通りロックカラムの自動インクリメントを追加する
これにより:
class Person < ActiveRecord::Base
alias_attribute :aliased_lock_version, :lock_version
end
Person.where(id: 1).update_all(aliased_lock_version: 10)
# => lock_version が 10 に更新され、自動インクリメントは付かないという期待通りの動作になります。
テスト追加
activerecord/test/cases/locking_test.rb に以下のようなテストが追加されています:
OptimisticLockingTest#test_update_all_with_lock_column_set_through_an_aliaspeopleテーブルに対するモデルでalias_attribute :aliased_lock_version, :lock_versionを定義update_all(aliased_lock_version: 10)実行後に:- 影響行数が 1 であること
lock_versionが 10 になっていること
このテストが、sqlite3 / postgresql / mysql2 すべてで「現行(main)では失敗 → PR適用で成功」することが確認されています。
- 影響範囲・注意点
- 影響対象:
- 楽観ロック(
lock_versionなど)を有効化しているモデル - かつ、そのロックカラムに
alias_attributeを定義しているケース - かつ、
update_allを使ってエイリアス名でロックカラムを更新しているケース
- 楽観ロック(
- 上記の条件に当てはまる場合:
- これまで SQLite/MySQL では「気づかないうちにロックカラムが自動インクリメントされ、指定した値は無視される」というバグがあった
- PostgreSQL では
update_all自体がエラーで落ちていた - 本PRにより、エイリアス名で指定してもカラム名で指定した場合と同じ挙動になる
注意点:
- ロックカラムを 一切指定していない
update_allの挙動(暗黙の自動インクリメント)は変わりません。 - 既存コードで、
update_allに渡すハッシュのキーにエイリアス名と実カラム名の両方を同時に指定しているような異常ケースがあった場合、これまでとは内部での扱いが変わる可能性があります(ただし、そもそもそのような指定は意味的に破綻しているので、実用上問題になることはほぼないはずです)。 insert_allでは既に同様の「エイリアス解決」が行われており、本PRはupdate_allをそれに揃える方向の変更です。
- 参考情報 (あれば)
- 該当PR:
rails/rails#57606「Resolve attribute aliases for the locking column inupdate_all」 - 関連API:
- ActiveRecord::Base#alias_attribute
- ActiveRecord::Relation#update_all
- 楽観ロック:
locking_enabled?,locking_column,lock_versionカラム
- 変更ファイル:
activerecord/lib/active_record/relation.rb
→ ロックカラムの自動インクリメント条件の判定ロジックを修正activerecord/test/cases/locking_test.rb
→ エイリアス経由でのロックカラム更新テストを追加activerecord/CHANGELOG.md
→ バグ修正として追記
#57617 Fix accepts_nested_attributes_for docs about _destroy and new records [ci skip]
マージ日: 2026/6/9 | 作成者: @55728
- 概要 (1-2文で)
accepts_nested_attributes_forのドキュメントにおいて、「新規レコードかつ_destroyが真なら無視される」という説明が、allow_destroy: trueが有効な場合にしか成り立たないにもかかわらず、無条件にそうであるかのように書かれていた問題を修正した PR です。コードの挙動は従来どおりで、ドキュメントだけを実装に合わせて訂正しています。
- 変更内容の詳細
問題となっていた点
accepts_nested_attributes_for でネストした属性を受け取るとき、内部では以下のようなロジックで「新規レコードを無視するかどうか」を判定しています:
reject_new_record?(association_name, attributes)
# => will_be_destroyed?(...) || call_reject_if(...)
will_be_destroyed?(association_name, attributes)
# => allow_destroy?(association_name) && has_destroy_flag?(attributes)つまり「_destroy が truthy だから新規レコードを無視する」ためには、
- その関連に対して
allow_destroy: trueが設定されていること
が前提です。allow_destroy: false(デフォルト)の場合は _destroy が true でもレコードは 無視されず、普通に build される というのが実装上の正しい挙動です。
この振る舞いは CVE-2015-7577 対応で意図的に導入されたものであり、テスト (test_reject_if_is_not_short_circuited_if_allow_destroy_is_false) により既にカバーされています。
ところがドキュメントでは、次の2点で「allow_destroy 前提」を書き漏らしていたため、挙動を誤解させる内容になっていました。
1) One-to-many の例
ドキュメントの一対多の例で、次のようなコードが載っていました(要点のみ):
class Member < ApplicationRecord
has_many :posts
accepts_nested_attributes_for :posts
end
member = Member.create(
name: 'joe',
posts_attributes: [
{ title: 'a' },
{ title: 'b' },
{ title: '', _destroy: '1' } # this will be ignored
]
)
member.posts.length # => 2ここでは accepts_nested_attributes_for :posts にオプション指定がないため、allow_destroy はデフォルトの false です。
その場合、実装上は _destroy: '1' が付いていてもその新規レコードは 無視されずに作成される ため、
member.posts.lengthは 3 になる- コメント
# this will be ignoredは誤り
となり、ドキュメントの例と実際の挙動が食い違っていました。
2) :reject_if オプションの説明
:reject_if の説明文において、オプションを指定しない場合のデフォルト挙動として、概ね次のような説明がありました(要旨):
:reject_ifを指定しない場合は、- "
_destroyが true と評価される値を持つ属性ハッシュを除いて、新しいレコードが作成される"
- "
しかし正確には、
- 「
_destroyが truthy であってもallow_destroy: falseのときは新規レコードはそのまま build される」
ため、_destroy による無視は「allow_destroy: true の場合に限る」という前提条件が抜けていました。
この PR での修正内容
One-to-many の例を実装に合うように修正
問題の例に対し、
allow_destroy: trueを明示的に付けることで、ドキュメント上の説明どおりの挙動になるようにしています:rubyclass Member < ApplicationRecord has_many :posts accepts_nested_attributes_for :posts, allow_destroy: true end member = Member.create( name: 'joe', posts_attributes: [ { title: 'a' }, { title: 'b' }, { title: '', _destroy: '1' } # this will be ignored ] ) member.posts.length # => 2これにより、
_destroy: '1'がついたハッシュはallow_destroy: trueにより無視され、postsは 2件になる、という説明が正しくなります。:reject_ifの説明文にallow_destroy前提を追記:reject_ifのセクションなど、_destroyの有無でレコードが無視される旨の説明をしている箇所に対して、- "
_destroyが truthy なら無視される" → 「allow_destroy: trueかつ_destroyが truthy なら無視される」
という形で、
allow_destroyが有効な場合にのみ成り立つことを明示するよう文言を修正しています。- "
コード変更はなし (ドキュメントのみ)
変更ファイルは
activerecord/lib/active_record/nested_attributes.rbのコメント/ドキュメント部分のみであり、ロジックには一切手を加えていません。[ci skip]が付いているのも、テストが不要なドキュメント変更であることを示しています。
- 影響範囲・注意点
実行時の挙動は変わりません
CVE-2015-7577 対応以降の挙動(allow_destroy: falseでは_destroy付きでも新規レコードは build される)はそのままです。今回の PR は、その挙動にドキュメントを揃えたものです。既存コードでの
_destroyの扱いを再確認すべきケース- ドキュメントだけを見て「
_destroyを付ければ新規レコードは作られない」と思い込み、allow_destroy: trueを付けていないフォーム/API 実装がある場合、- 実際には新規レコードが作成されている可能性があります。
- 特に
fields_for+accepts_nested_attributes_forを用いたフォームで、「空のフィールドに_destroyを付ければスキップされるはず」と考えていた場合は注意が必要です。 - この PR 自体は仕様を変えていませんが、「どちらが正しいのか」をドキュメントが明確にしたことで、誤った前提だったコードに気付くきっかけとなるかもしれません。
- ドキュメントだけを見て「
新規実装時のポイント
「フォームでユーザーが『削除』を押した行は保存しない」など、
_destroyで新規レコードを抑制したい場合、- 対応する関連に 必ず
allow_destroy: trueを付ける 必要があります。
- 対応する関連に 必ず
それとは別に、「空のフィールドは新規レコードを作りたくない」といった要件は、
reject_if:で明示的に制御するのが安全です:rubyaccepts_nested_attributes_for :posts, allow_destroy: true, reject_if: proc { |attrs| attrs['title'].blank? && attrs['_destroy'] != '1' }
- 参考情報 (あれば)
関連する既存テスト:
test_reject_if_is_not_short_circuited_if_allow_destroy_is_false
(allow_destroy: falseのときは_destroyが truthy でもreject_if判定がショートサーキットされず、新規レコードが build されることを検証)セキュリティ・バグフィックスの背景:
- CVE-2015-7577(nested attributes の
destroy/reject_if周りの挙動に関する脆弱性)対応時に、allow_destroyが偽の場合に_destroyを尊重しないという仕様が導入されています。 - 本 PR は、その仕様に合わせてドキュメントを修正し、「
_destroyが効くのはallow_destroy: trueのときだけ」という前提を明示したものです。
- CVE-2015-7577(nested attributes の
#57600 Fix store_accessor read mutating a NULL structured column
マージ日: 2026/6/9 | 作成者: @55728
- 概要 (1-2文で)
store_accessorを、NULLのjson/jsonb/hstoreカラムに対して「読むだけ」で呼び出したときに、レコードが変更済み扱いになりNULLが{}で上書き保存されてしまうバグを修正する PR です。- 修正により、「読み取り専用の操作」がレコードを汚染(dirty 化)したり、DB 上の
NULLを暗黙的に{}に書き換えることがなくなります。
- 変更内容の詳細
問題の挙動
以下のようなモデル定義で、data が DB 上で NULL のとき:
class Doc < ActiveRecord::Base
store_accessor :data, :color
end
doc = Doc.create!(name: "a") # data は NULL のまま
doc = Doc.find(doc.id)
doc.changed? # => false
doc.color # 読み出しのみ
doc.changed? # => true (本来は false であるべき)
doc.changes # => {"data" => [nil, {}]} (NULL が {} になった扱い)この状態で、name だけ変えて保存すると:
doc.name = "b"
doc.save!
Doc.connection.select_value("SELECT data FROM docs WHERE id = #{doc.id}")
# => "{}" (DB 上の data が NULL から {} に書き換えられてしまう)つまり、
- 「アクセサを読むだけ」で
- レコードが
changed? == trueになる dataカラムの DB 値がNULL→"{}"に変化する
- レコードが
という副作用が発生していました。
原因
store_accessor の実装で使われている HashAccessor.read が、内部で prepare を呼んでおり、その prepare が「nil のときに {} を生成して、レコードに書き戻す」という処理を行っていたためです。
def self.prepare(object, attribute)
store_object = object.public_send(attribute)
if store_object.nil?
store_object = {}
object.public_send(:"#{attribute}=", store_object) # ← 読み取り時にも書き戻していた
end
store_object
end結果として、「読むだけ」のはずの color アクセサが data カラムを {} に更新し、ActiveRecord の dirty tracking 上も data が変わったとみなされていました。
なお、coder: を使う古い store(シリアライズあり)の場合は、型キャスト層 (IndifferentCoder.load) で NULL → {} 変換が行われるため、そもそも nil にならず、この問題には該当しません。
修正方針
- 「読み取りパス」と「書き込みパス」を分離し、読み取り時にはレコードを書き換えないようにする。
- 書き込み時はこれまで通り
prepareを通し、NULLのカラムに初めて値を書くときは{}を生成して保存できるようにする。
HashAccessor.read の変更
以前は read が prepare を経由していたのを、非破壊な get ベースの実装に変更:
# 変更後: HashAccessor
def self.read(object, attribute, key)
get(object.public_send(attribute), key)
endget は、すでに存在しているハッシュからキーを読むだけで、nil のときも何も書き戻しません。
IndifferentHashAccessor の対応
store_accessor は hash に対して「シンボル/文字列どちらのキーでもアクセスできる」(indifferent access)ことを保証したいので、IndifferentHashAccessor にも非破壊な get を追加し、read でこれを使うようにします:
# IndifferentHashAccessor
def self.get(store_object, key)
if store_object
IndifferentCoder.as_indifferent_hash(store_object)[key]
end
endこれにより、
- 読み取り時:
IndifferentCoder.as_indifferent_hashで一時的に indifferent hash として扱い、その場で値を取得するだけ- レコードの属性(DB にマッピングされるハッシュ)は書き換えない
- 書き込み時:
- 既存の
prepare経由の動作を維持し、nilの場合は{}を生成して書き込む
- 既存の
という振る舞いになります。
テスト
Admin::User の既存の json カラム json_options と store_accessor :json_options, :enable_friend_requests を使い、以下を確認するテストを追加:
json_optionsがNULLの状態でenable_friend_requestsを読むだけではchanged?がfalseのままであること- 別の属性だけを変更して保存しても、
json_optionsがNULLのままであること({}に書き換えられないこと)
MariaDB 向けには、既存の JSON 関連テストと同じ skip 条件を利用。
SQLite3 / PostgreSQL / MySQL2 すべてで、このテストが fail → pass になることを確認済みで、store_test など周辺テストもグリーンであることが確認されています。
- 影響範囲・注意点
- 対象:
- ネイティブな構造化型 (
json/jsonb/hstore等) を持つカラムに対して、store_accessorを定義しているケース。 - DB 上の値が
NULLで、かつ accessor を読み出しているコードがある場合。
- ネイティブな構造化型 (
- 変わる挙動:
- これまで:
doc.colorのような読み取りだけでdoc.changed?がtrueになり、次のsaveでNULL→{}に上書きされていた。
- これから:
- 読み取りだけでは dirty にならず、
NULLも維持される。
- 読み取りだけでは dirty にならず、
- これまで:
- 想定される「副作用」(ほとんどは期待される挙動への修正ですが、挙動変化として注意すべき点):
- 「アクセサの読み取りがカラムを
{}に初期化してほしい」という前提のコードがあった場合、その前提は成り立たなくなります。- 例:
doc.colorを読むだけでdataが必ず{}以上になっていることを期待して、その後doc.data[:foo] = barのように直接操作していた場合、dataがnilのままになりNoMethodErrorになる可能性があります。 - そのような場合は、「読み取り」ではなく「書き込み」操作(例:
doc.color ||= '...'やdoc.json_options ||= {}; ...)で初期化するようにコード側を修正すべきです。
- 例:
changed?やchangesに依存したロジック(save if changed?、after_save if saved_change_to_data?等)が、本来の意図どおり「本当に変更があったときだけ」動くようになります。- 以前は「読むだけで changed になる」ことを前提にしたコードが動作していた場合、そのコードは発火しなくなる可能性がありますが、それ自体が元々バグ依存のロジックと考えられます。
- 「アクセサの読み取りがカラムを
互換性面では、「バグ修正として妥当な挙動変更」であり、通常のアプリケーションコードにとっては改善とみなしてよい内容です。
- 参考情報 (あれば)
- 対象 PR: https://github.com/rails/rails/pull/57600
- 関連するドキュメント記述(今回のバグが特に当たっていたパターン):
- 「ネイティブ JSON / hstore カラムには
storeではなくstore_accessorを使うべき」というガイドラインに従った場合に、このバグに直撃していました。
- 「ネイティブ JSON / hstore カラムには
- 実装的なポイント:
- 「読み取りパスでモデル属性を書き換えない」というのは、ActiveRecord の dirty tracking としても自然な設計なので、今後この方針に沿った変更が増える可能性があります。
#57622 Fix Range#include? / Range#=== raising on exclusive non-integer sub-ranges
マージ日: 2026/6/9 | 作成者: @55728
- 概要 (1-2文で)
ActiveSupport が拡張しているRange#include?/Range#===の「レンジ同士の包含チェック」で、排他的終端かつ非整数 (Float,Timeなど) のサブレンジを渡すとTypeErrorで落ちていた問題を修正した PR です。Ruby 本体のRange#cover?(range)に処理を委譲することで、すべての型・端点組み合わせで正しく真偽値を返すようにしています。
- 変更内容の詳細
問題となっていた実装
ActiveSupport では、Range#include? / Range#=== に「引数が Range のときは『完全に含まれるか』を判定する」という拡張が入っていました。その実装の一部は以下のように、引数側 Range の最大要素を独自に計算していました:
# activesupport/lib/active_support/core_ext/range/compare_range.rb (旧)
value_max = !exclude_end? && value.exclude_end? ? value.max : value.lastここで value は引数として渡された Range(サブレンジ)です。
value.exclude_end?が真(排他的終端)で、- かつ「自分(self)の Range が終端を含む」場合に、
value.maxを呼んでサブレンジの最大要素を求める、
というロジックになっていました。
ところが Ruby の Range#max は「範囲を列挙して最大値を返す」ため、
- 終端が
Integer以外の排他的 Range(例:1.0...5.0,t...(t + 10.minutes))では - 「非整数の排他的終端は列挙できない」「Time からは順次イテレートできない」
といった理由で TypeError を投げます。
例:
(1.0..10.0).include?(2.0...5.0)
# => TypeError: cannot exclude non Integer end value
t = Time.now
(t..(t + 1.hour)).include?(t...(t + 10.minutes))
# => TypeError: can't iterate from Timeつまり「レンジがレンジを含むか?」という単なる包含チェックが、
FloatTimeDateTimeActiveSupport::TimeWithZoneBigDecimal
などで排他的終端を使った時にクラッシュしてしまう状態でした。
なお value.max を使っていた目的は「離散な整数レンジの排他的終端をうまく解釈する」ためで、典型的には次のようなケースを true にしたい、という用途です:
(1..10).include?(1...11) # => true にしたいしかし整数以外の型ではこのロジックが破綻しており、今回のバグにつながっていました。
修正方針
Ruby 2.6 以降では Range#cover? が Range を引数に取れるようになっており、「この Range が引数 Range を完全に含むか」を正しく判定してくれます。Rails のサポート Ruby バージョンは 2.6 より新しいため、ActiveSupport 独自の hand-rolled 実装をやめて、Ruby 本体の Range#cover? に委譲する実装に差し替えています。
新しい実装イメージ:
def ===(value)
if value.is_a?(::Range)
cover?(value)
else
super
end
end
def include?(value)
if value.is_a?(::Range)
cover?(value)
else
super
end
endポイント:
value.is_a?(::Range)のときだけcover?(value)を使う- それ以外(スカラー値: 数値, 文字列, Time など)の場合は
superで Ruby 標準のRange#include?/Range#===にそのまま委譲
→ 既存の('a'..'f').include?('c')等の挙動は一切変わらない
Range#cover? は
- 両端 inclusive / どちらか exclusive / 両方 exclusive
- 逆向きレンジ (e.g.
5..3) - 空の exclusive レンジ (e.g.
3...3) - endless / beginless レンジ
- レンジ同士が部分的にしか重ならないケース
など、元々 ActiveSupport がハンドリングしていた多くのパターンを含め、Ruby 本体の定義に沿って一貫した結果を返すようになっています。
修正後の挙動例
TypeError が発生していたケース:
(1.0..10.0).include?(2.0...5.0)
# 旧: TypeError
# 新: true
(1.0..10.0).include?(2.0...10.5)
# 旧: TypeError
# 新: false
t = Time.utc(2005, 12, 10, 15, 30)
(t..(t + 2.hours)).include?((t + 30.minutes)...(t + 90.minutes))
# 旧: TypeError
# 新: true従来既にテストされていたパターン(挙動は維持されている):
- 同じ端点・同じ inclusive/exclusive の Range 同士
- 排他的終端整数レンジ (
1..10vs1...11) - 逆向きレンジ (
5..3) - 空レンジ (
3...3) - endless/beginless レンジ
- オーバーラップのみで完全包含していないケース
などは、Range#cover? でも同じ真偽値が返ることがテストで確認されています。
- 影響範囲・注意点
影響範囲
- 対象:
- ActiveSupport をロードした環境で、
Range#include?/Range#===に Range を引数として渡しているコード 全般
とくに影響が大きいのは次のようなユースケースです:
- 時間窓を Range で表現し、その Range に別の時間 Range(サブウィンドウやスロット)を含められるかをチェックしているコード
- 例:
Time/TimeWithZone/DateTimeの Range
- 例:
- 許容誤差帯やしきい値を
Float/BigDecimalの Range で扱っているコード - そのサブレンジとして排他的終端 (
...) を使っているコード
これらがこれまで TypeError で落ちていた場合でも、今後は true / false のどちらかを返すようになります。
注意点
例外から「単純な真偽値」に変わる
- 今まで
TypeErrorを前提に rescue していた場合、その rescue 経路は通らなくなります。 - ロジックとして「例外が出たら false 扱い」としていたようなコードは、
include?の戻り値だけで判定できるようになる一方で、挙動が変わることに注意が必要です。
- 今まで
挙動は Ruby 本体の
Range#cover?(range)に一致- ActiveSupport 独自実装から Ruby 本体の仕様に寄せたため、「以前のバグを利用していた」ようなコードがあればその挙動は変わります。
- ただし PR 説明によると、既存のテストケースの全ては
Range#cover?でも同一結果が出ているため、実質的には「これまで落ちていたパターンが落ちなくなる」以外の挙動変更はほぼないと見てよいです。
スカラー引数 (
range.include?(scalar)) の挙動は不変- 引数が Range でない場合は従来どおり
super呼び出しなので、ここに後方互換性の問題はありません。
- 引数が Range でない場合は従来どおり
- 参考情報 (あれば)
- Ruby 本体の
Range#cover?ドキュメント- 範囲が値を「カバーするかどうか」を比較演算なしで判定するメソッドとして定義されており、Ruby 2.6 以降は引数に Range も受け付けます。
- この PR で追加されたテスト (
activesupport/test/core_ext/range_ext_test.rb)test_should_include_exclusive_end_float_rangetest_should_not_include_exclusive_end_float_range_past_endtest_should_include_exclusive_end_time_range
→ バグ再現ケースとその修正後の期待挙動が明示されているので、同様のユースケースを持つアプリケーションでの挙動確認の参考になります。
#57614 Allow insert! to accept the :unique_by option
マージ日: 2026/6/9 | 作成者: @55728
- 概要 (1-2文で)
ActiveRecord::Relation#insert!で、これまで渡せなかったunique_by:オプションを受け付けるようにし、insert/insert_all!とインターフェースを揃えた修正です。これにより、insert!でも一意制約を考慮した upsert 風の挙動が利用可能になります。
- 変更内容の詳細
何が問題だったか
もともと以下のような状況でした:
insert_all!はunique_by:オプションを受け取れるinsertもunique_by:を受け取れる- しかし
insert!のメソッド定義にunique_by:が含まれておらず、呼び出し側でunique_by:を指定するとArgumentErrorになる
# 以前の定義イメージ
def insert!(attributes, returning: nil, record_timestamps: nil)
insert_all!([ attributes ], returning: returning, record_timestamps: record_timestamps)
end
Book.insert({ id: 1, name: "X", author_id: 1 }, unique_by: :index_books_on_isbn) # OK
Book.insert!({ id: 2, name: "X", author_id: 1 }, unique_by: :index_books_on_isbn) # ArgumentErrorドキュメント上は insert! も insert_all! と同じオプションが使えるように読めるのに、実装が追いついていなかった状態です。
何を修正したか
insert! のシグネチャを修正し、unique_by: を受け取り insert_all! にそのまま渡すようにしました。
イメージとしては以下のような変更です(実際のコードは Relation クラス内にあります):
# 修正後のイメージ
def insert!(attributes, returning: nil, record_timestamps: nil, unique_by: nil)
insert_all!(
[attributes],
returning: returning,
record_timestamps: record_timestamps,
unique_by: unique_by
)
endこれにより、以下のようなコードが問題なく動作するようになります:
# 一意制約付き index_books_on_isbn を衝突検知に使う例
Book.insert!(
{ id: 2, name: "X", author_id: 1, isbn: "978-..." },
unique_by: :index_books_on_isbn
)テストの追加
activerecord/test/cases/insert_all_test.rb に以下のようなテストが追加されています:
test_insert_bang_accepts_unique_by- すでに存在する
test_insert_all_bang_accepts_unique_byに対応するテスト supports_insert_conflict_target?を満たすアダプタ(PostgreSQL / SQLite 等)でのみ実行- シグネチャの修正を戻すと
ArgumentError: unknown keyword: :unique_byで落ちることを確認済み
- すでに存在する
ドキュメント / CHANGELOG
activerecord/CHANGELOG.mdにユーザー向けのエントリが追加され、insert!がunique_by:に対応したことが明示されています。
- 影響範囲・注意点
- 影響クラス:
ActiveRecord::Relation#insert!を利用しているコード - 互換性:
unique_by:を新たに受け付けるだけなので、既存コード(unique_by:を渡していないもの)は基本的に非互換影響はありません。- Ruby のキーワード引数仕様上、
unique_by:を追加しても、追加前の引数呼び出し形式とは衝突しにくいため、後方互換性は高いです。
- DB依存:
unique_by:自体は、内部的にINSERT ... ON CONFLICT ...などの機構を使うため、DB アダプタの対応状況に依存します。- これまで
insert/insert_all!でunique_by:を使えていた環境であれば、そのままinsert!でも利用可能になります。
- 利用時の注意:
unique_by:には通常、ユニークインデックス / 一意制約 の名前、または列名の配列を渡します(PostgreSQL のON CONFLICTターゲットに対応)。- インデックス名やカラム指定が実際の DB スキーマと一致していないとエラーになるので、
insert!に限らずinsert_all!/upsert_allと同様の注意が必要です。
- 参考情報 (あれば)
- 関連メソッド:
ActiveRecord::Persistence::ClassMethods#insertActiveRecord::Persistence::ClassMethods#insert_all!- これらと
insert!のオプションが揃ったことで、バルクインサートと単一レコードインサートで同じ API を利用しやすくなりました。
unique_byの詳細な仕様は Rails ガイド / API ドキュメントの「insert_all / upsert_all」周りを参照すると理解しやすいです。
#57581 Support only: and except: on _deliver callbacks in ActionMailer
マージ日: 2026/6/9 | 作成者: @excid3
- 概要 (1-2文で)
Action Mailer の_deliverコールバック(before_deliver,after_deliver,around_deliver)で、only:/except:オプションが正しく機能するように修正・拡張されたPRです。これにより、コントローラの*_actionコールバックと同様の書き方で、特定のメールアクションに対してのみコールバックを適用できるようになります。
- 変更内容の詳細
何が問題だったか
以前追加された Action Mailer の
_deliver系コールバック(例:before_deliver)にonly:/except:オプションを渡しても、それらが無視されていました。- 例:ruby上記の
class UserMailer < ApplicationMailer before_deliver :log_delivery, only: :welcome_email endonly: :welcome_emailが効いていなかった。
- 例:
これは AbstractController(ActionController などで使われる)のコールバックとは挙動が異なり、一貫性がない状態でした。
何をしたか
AbstractController のコールバック (
*_actionコールバック) と同じ正規化ロジック(normalization wrapper)を_deliverコールバックにも適用するように変更。- 参照されている箇所:
abstract_controller/callbacks.rbのnormalize_callback_params相当の処理set_callback :process_action, ...で使われているオプションの解釈ロジック
- 参照されている箇所:
具体的には、Action Mailer のコールバック定義部分(
actionmailer/lib/action_mailer/callbacks.rb)で、only:/except:などを含むオプションを AbstractController と同じ形に正規化してからコールバックを登録するようにした。
実際にどう書けるようになるか(サンプル)
before_deliver, after_deliver, around_deliver で、以下のような指定が有効になります:
class UserMailer < ApplicationMailer
# 特定のメールアクションにだけ適用
before_deliver :log_welcome_delivery, only: :welcome_email
# 複数アクションに適用
after_deliver :track_marketing_emails, only: [:campaign_email, :newsletter_email]
# 特定のアクションだけ除外
around_deliver :with_timing, except: :system_notification
def welcome_email(user)
mail(to: user.email, subject: "Welcome!")
end
def campaign_email(user)
mail(to: user.email, subject: "Big sale!")
end
def system_notification(user)
mail(to: user.email, subject: "System notice")
end
private
def log_welcome_delivery(mail)
Rails.logger.info("[MAIL] welcome_email delivered to #{mail.to}")
end
def track_marketing_emails(mail)
# GA / Mixpanel などへの連携
end
def with_timing
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
Rails.logger.info("[MAIL] delivery took #{elapsed}s")
end
end上記のような only: / except: が、コントローラの before_action などと同じ感覚で動作するようになります。
テスト・ドキュメント・CHANGELOG
actionmailer/test/callbacks_test.rbに 56 行のテストが追加され、only:/except:の動作が確認されています。actionmailer/test/mailers/callback_mailer.rbにテスト用の Mailer が追加され、各種パターンのコールバック適用が検証されています。actionmailer/CHANGELOG.mdにエントリが追加され、挙動変更が明記されています。- guides の
configuring.mdにも4行追加され、ドキュメント上も_deliverコールバックのオプション使用例/説明が更新されています。
- 影響範囲・注意点
影響を受けるのは
_deliverコールバックのみbefore_deliver,after_deliver,around_deliverでonly:/except:を指定している(もしくは今後指定する)コードが対象です。- それ以外の Action Mailer の API には影響しません。
既存コードの互換性
- これまで
only:/except:は“無視されていただけ”なので、多くの場合は新たに意図したとおりにコールバックが発火するようになるだけです。 - ただし、これまで「全アクションにコールバックが走る前提」でワークアラウンドしていた場合には挙動が変わる可能性があります。
- 例: 「
only:が効かない想定で内部条件分岐を書いていた」等。
- 例: 「
- これまで
コントローラとの一貫性が向上
before_action/after_action/around_actionとほぼ同じ感覚で_deliverコールバックを使えるようになるため、学習コスト/驚きは減ります。- 特に
only: [:action1, :action2]やexcept: :some_actionのような典型的な書き方が Controller と Mailer で揃うのはメリットです。
バージョン依存
- この PR は main ブランチ(将来の Rails バージョン)向けであり、現時点でリリース済みの Rails にはまだ含まれていない可能性があります。
- 実際に利用する場合は、使っている Rails バージョンの CHANGELOG / リリースノートで、この PR (#57581) が取り込まれているか確認してください。
- 参考情報 (あれば)
- PR本体: https://github.com/rails/rails/pull/57581
- 関連Issue: https://github.com/rails/rails/issues/50830
_deliverコールバック追加時の PR(背景): https://github.com/rails/rails/pull/47630- 対応する AbstractController の実装(参考):
#57595 Forward blob metadata to MirrorService mirrors
マージ日: 2026/6/9 | 作成者: @maksim-romanov
- 概要 (1-2文で)
Active Storage の MirrorService がミラー先ストレージにアップロードするとき、content_typeやfilenameなどの blob メタデータを正しく引き継ぐように修正した PR です。これにより、S3 / R2 / Azure / GCS などのミラー先を直接参照した場合でも、オリジナルと同じヘッダでオブジェクトが配信されるようになります。
- 変更内容の詳細
バグの内容
ActiveStorage::Service::MirrorService#mirror は、非同期ジョブ (MirrorJob) から呼ばれ、プライマリサービスに保存された blob を複数のミラーに複製する役割を持っています。
従来の実装では、ミラーに対して以下のように checksum のみを渡して upload を呼んでいました:
mirror.upload(key, io, checksum: checksum)その結果、ミラー側のオブジェクトには以下のメタデータが反映されていませんでした:
content_typefilenamedispositioncustom_metadata
クラウドストレージ (S3 / Cloudflare R2 / Azure / GCS) では、これらはアップロード時ヘッダとして保存されるため、欠落すると:
- ブラウザから直接バケット URL にアクセスしたとき
- CDN カスタムドメイン経由でオリジンを直接叩くとき
<img src="https://bucket.example.com/path/to/object">のように直接参照するとき- 動画などで Range Request を直接ストレージに投げるとき
に Content-Type: application/octet-stream になってしまい、画像や動画として正しく扱われない・プレビューされない、などの問題が発生していました。rails_blob_url 経由のアクセスでは Rails が署名付き URL に response-content-type を付けて上書きするため、この問題は表面化しません。
修正の要点
この PR では、ミラーリング時にもプライマリサービスと同等のメタデータを渡すようにしています。
1. mirror 内で blob をキーから取得
ActiveStorage::Service::MirrorService#mirror の中で、アップロード対象の blob をキー (key) から検索します。
疑似コードイメージ:
def mirror(key, checksum:)
# 追加: key から blob を引く
blob = ActiveStorage::Blob.find_by(key: key)
service_metadata = blob&.service_metadata || {}
@mirrors.each do |mirror|
mirror.upload(key, io, checksum: checksum, **service_metadata)
end
end実際には、service_metadata というメソッドを通して、content_type・filename・disposition・custom_metadata など、サービス向けのメタデータ一式を取得してミラーに渡します。
2. Blob#service_metadata を public に昇格
これまでは ActiveStorage::Blob#service_metadata は private メソッドでしたが、以下のような他の内部 API (unfurl, upload_without_unfurling, compose, mirror_later) と同じく、クラス間の内部利用が想定されているため:
- 可視性を
publicに変更 - ただし public API として外部利用を推奨するわけではないので
# :nodoc:を付け、ドキュメントには出さない
という形で公開範囲を整理しています。
これにより、MirrorService から blob.send(:service_metadata) のようなメタプロ的な呼び出しをせずに、普通に blob.service_metadata を呼べるようにした、という設計上の改善も含まれています。
3. Blob が見つからない場合のフォールバック
ActiveStorage::Blob のレコードが既に削除されていて、しかしストレージ上にはオブジェクトが残っている「orphan key」のケースを考慮し、find_by が nil を返した場合は:
service_metadata = {}と空ハッシュを渡すようにしています。
これにより、少なくとも従来の挙動 (メタデータ無しでアップロード) と整合的な動作を維持しつつ、レコードが存在する正常ケースではフルメタデータを渡す、という挙動になります。
4. テストと CHANGELOG の追加
activestorage/test/service/mirror_service_test.rbに MirrorService が blob の service_metadata をミラーに引き渡していることを検証するテストを追加。activestorage/CHANGELOG.mdに、この挙動変更 (バグ修正) に関する記述を追加。
- 影響範囲・注意点
影響を受けるサービス
- 影響あり:
ActiveStorage::Service::MirrorServiceを利用している構成- ミラー先として S3 / Cloudflare R2 / Azure / GCS など、アップロード時メタデータを保持するクラウドストレージ
- 影響なし:
- Disk サービス (ローカルストレージ)
→ もともと HTTP ヘッダをストレージ側に保存しないため、今回の修正対象外
- Disk サービス (ローカルストレージ)
実務上の影響
- 今後作成されるミラーオブジェクトは、オリジナルと同じ
Content-Type/Content-Disposition/ カスタムメタデータで保存される - そのため:
- CDN カスタムドメインでバケットを直接 origin にしている構成
<img src="...">/<video src="...">などでストレージ URL を直接参照しているケース- プレサインド URL ではなく、ストレージの公開 URL をそのまま使うケース
で、正しい MIME Type などが反映されるようになる
既に壊れたミラーオブジェクト (過去にメタデータ抜きでアップロードされたもの) については、この PR を適用しても自動で修復されるわけではありません。必要であれば:
- 対象オブジェクトを再ミラーリングする
- あるいは、ミラー先のオブジェクトを削除し、Active Storage 側から再アップロードをトリガーする
などの対応が別途必要になります。
互換性面の注意
Blob#service_metadataがpublicになりましたが、# :nodoc:が付いているため、Rails の内部 API という扱いです。
アプリケーションコードからの直接利用は可能ではあるものの、今後の互換性は保証されていない点に注意してください。- 既存の MirrorService の API 形状 (public メソッドシグネチャなど) は変わっていないため、Rails アプリ側のコードを書き換える必要はありません。
- 参考情報 (あれば)
- この PR が修正する Issue:
#57270
(R2 + カスタムドメイン CDN でのContent-Type欠落問題が報告されている) - 代替案 PR:
#57276- 同じバグを解決する別実装
- この PR との主な違い:
Blob#service_metadataを public にする代わりにsendで呼ぶアプローチ- 本 PR は Rails 内部 API の一貫性 (他の内部メソッドと同様に public +
:nodoc:) を重視し、その設計に揃えている
- 関連する内部 API:
ActiveStorage::Blob#unfurlActiveStorage::Blob#upload_without_unfurlingActiveStorage::Blob#composeActiveStorage::Blob#mirror_later
これらと同列に service_metadata を「内部向け public」として露出させることで、MirrorService からの利用を素直に書けるようにした、という位置付けの変更です。
#53161 Add bundler-cache feature in dev container setup
マージ日: 2026/6/9 | 作成者: @viktorianer
- 概要 (1-2文で)
Dev Container で Rails 開発を行う際に、Bundler を含む Ruby 環境(.rbenvディレクトリ)をボリュームとしてキャッシュする仕組みが追加され、コンテナ再作成時の gem 再インストールコストを削減する変更です。Rails の devcontainer 用ジェネレータとそのテストが、この新しいキャッシュ戦略に合わせて更新されています。
- 変更内容の詳細
2-1. Dev Container 設定へのボリューム追加
railties/lib/rails/generators/rails/devcontainer/templates/devcontainer/compose.yaml.tt に、.rbenv ディレクトリをホスト側ボリュームとしてマウントする設定が追加されています。
イメージとしては、以下のような変更が入っていると考えられます(概念的なサンプル):
services:
app:
volumes:
# 既存のマウント設定に加えて…
- rails-rbenv:/home/vscode/.rbenv
volumes:
rails-rbenv:ポイント:
.rbenv全体をボリュームとしてキャッシュすることで、Ruby バージョンや gem(Bundler でインストールされたものを含む)の再インストールを避ける。- 以前の PR (#53123) で提案されていた
ghcr.io/rails/devcontainer/features/bundler-cacheではなく、標準的な Docker ボリュームの仕組みだけで実現している。
2-2. テストコードの更新
以下のテストが更新されています:
railties/test/commands/devcontainer_test.rbrailties/test/generators/app_generator_test.rbrailties/test/generators/devcontainer_generator_test.rb
主な変更点:
devcontainerコマンドやジェネレータが生成するcompose.yamlに、.rbenvボリュームの定義・マウントが含まれていることを検証するアサーションが追加。- 既存テストの期待値(生成される compose 設定)を、新しいボリューム定義に合わせて修正。
これにより、今後 Rails プロジェクトで --devcontainer オプション付きで生成される設定や bin/rails devcontainer 相当のコマンド実行結果が、常に .rbenv キャッシュを含むことが保証されます。
- 影響範囲・注意点
影響範囲
- Rails の devcontainer テンプレートから生成される Docker Compose 設定に、
.rbenv用のボリュームが追加されます。 - これにより、Dev Container を再作成しても gem の再インストールが基本的に不要になり、起動が高速化されます。
- Dev Container を前提とした Rails アプリのテンプレート生成 (
rails new myapp --devcontainerなど) に影響します。
- Rails の devcontainer テンプレートから生成される Docker Compose 設定に、
注意点
.rbenvディレクトリ全体がキャッシュ対象になるため、Ruby バージョンや gem セットアップを大きく変えた場合は、ボリュームを再作成(削除して作り直す)しないと古い状態が残る可能性があります。- すでに独自に
.rbenvなどをマウントしている devcontainer 設定がある場合は、生成物とコンフリクトしないか確認が必要です(ただし、この PR は Rails が生成するテンプレート側の変更のみ)。 - 特定の CI 環境や共有環境で
.rbenvキャッシュを利用する際は、「誰がそのボリュームを共有するのか」「パーミッションはどうか」を意識する必要があります。
- 参考情報 (あれば)
- 本 PR: https://github.com/rails/rails/pull/53161
- 関連 PR(feature/bundler-cache を使う案): https://github.com/rails/rails/pull/53123
- 提案者によるサンプル Rails アプリでの実例:
https://github.com/viktorianer/rails8-devcontainer-enhancements/pull/13
この PR により、Rails の公式 devcontainer サポートが、特別な feature への依存なしに「シンプルなボリュームキャッシュ戦略」で bundler 依存関係を高速化する方向へ整理されたと言えます。
#57574 Make the backtrace cleaner ractor shareable by default
マージ日: 2026/6/9 | 作成者: @andrewn617
- 概要 (1-2文で)
Rails のバックトレースクリーナー(Rails.backtrace_cleaner/ActiveSupport::BacktraceCleaner)が、デフォルトで Ractor 共有可能になるように実装を変更した PRです。これにより、Rails.applicationを Ractor で安全に共有しやすくなります。
- 変更内容の詳細
背景
- 目標:
Rails.applicationをRactor.shareable?にしたい。 - 問題:
Rails.applicationの内部にはRails.backtrace_cleanerが含まれており、その中の「デフォルトのフィルタ・サイレンサー」が Ractor 共有不可能なProcになっていた。 - Ractor 共有可能な
Proc(Ractor.shareable_proc) ではselfがnilになる点があり、「ユーザが追加するフィルタ / サイレンサー」を一律で shareable にすると、selfに依存しているブロックが実行時までエラーにならず、危険な「踏み抜き (footgun)」になる。
この PR では「Rails が内部的に設定しているデフォルトのブロック」だけを Ractor 共有可能にし、ユーザ追加分は従来通りの挙動に留めています。
主な変更点
1) ActiveSupport::BacktraceCleaner のデフォルトフィルタ/サイレンサーを shareable 化
activesupport/lib/active_support/backtrace_cleaner.rb の変更:
- デフォルトで設定されるフィルタ・サイレンサーの
ProcをRactor.shareable_procでラップするように変更。 - これにより、
ActiveSupport::BacktraceCleaner.newで生成されるインスタンスの初期状態は Ractor 共有可能になります(※ただし、ユーザが後から非 shareable な Proc を追加した場合はその限りではない)。
イメージ的には:
# 変更前(概念的な例)
add_silencer { |line| line.include?("/gems/") }
# 変更後(概念的な例)
add_silencer Ractor.shareable_proc { |line| line.include?("/gems/") }※ 実際には、実装側で shareable にするようにしており、アプリコード側で Ractor.shareable_proc を明示的に呼ぶ必要はありません。
2) Rails.backtrace_cleaner の初期化ロジックの調整
railties/lib/rails/backtrace_cleaner.rb:
- Rails が提供する
Rails.backtrace_cleanerに設定している Rails 独自のサイレンサーやフィルタもRactor.shareable_proc化。 - Rails 側で追加している「Rails のノイズを消すためのルール」(
bin/,vendor/bundle/,railties等を消すようなもの)が Ractor からも安全に利用できるようになった。
結果として:
Rails.backtrace_cleaner # => Ractor から参照しても shareable なオブジェクトになる想定3) Ractor 用テストの追加
activesupport/lib/active_support/testing/ractors_assertions.rbに Ractor 関連のアサーションヘルパーを追加。activesupport/test/backtrace_cleaner_test.rbrailties/test/backtrace_cleaner_test.rb
上記テストで、
- デフォルトの
BacktraceCleanerが Ractor 内からも正常に使えること - Ractor 間で共有しても例外が出ないこと
などを検証しています。
- 影響範囲・注意点
影響範囲
- Rails 既定の挙動
デフォルトのバックトレースフィルタ/サイレンサーのロジック自体は変わっていないため、通常の(非 Ractor)使用における表示結果は従来と同じはずです。 - Ractor 利用時の改善
Rails.applicationやRails.backtrace_cleanerをそのまま Ractor に渡しやすくなり、Ractor ベースの並列実行でバックトレースクリーニング機能が使いやすくなります。
注意点
ユーザが追加するフィルタ/サイレンサーはデフォルトでは shareable にならない
rubycleaner = Rails.backtrace_cleaner # これは shareable ではない Proc(self を前提にしているかもしれない) cleaner.add_silencer do |line| some_helper(line) # self に依存したりする可能性がある endこういったユーザ定義ブロックは Ractor 共有可能には「自動では」なりません。その理由は PR 説明の通り:
Ractor.shareable_procにするとselfがnilになる。- ブロック内部で
selfに依存している場合、追加時には気づかず、実行時に NoMethodError になる。 - それを避けるため、フレームワーク側の都合でユーザブロックを強制的に shareable にはしていない。
もしユーザ側で Ractor 対応したい場合は、自分で shareable な Proc を意識して書く必要がある(例:
selfに依存しないロジックに分離する、Ractor.shareable_procを自前で使う、等)。将来的な API 拡張の示唆
PR 本文で触れられている通り:
- 「shareable を opt-in で指定できる API」を用意する
- Prism などでブロックの AST を解析し、
self参照がない場合のみ shareable にする
といった改善案はあるが、この PR 時点ではまだ導入されていません。
class macro 系のブロックは今後 shareable 化される予定
instance_execで評価するような「クラスマクロ的な DSL ブロック」は、デフォルトで shareable にしやすいので、別 PR でそちらを対応予定と述べられています。この PR はあくまで backtrace cleaner 周りだけ。
- 参考情報 (あれば)
- Ractor の仕様(特に shareable オブジェクト、
Ractor.shareable_procの挙動)Ractor.shareable?(obj)がtrueなら別 Ractor との共有が可能。Ractor.shareable_proc { ... }で生成した Proc は- 自身が shareable なクロージャ
- ただし実行時の
selfはnilになる
- 実運用で Ractor を使う場合:
Rails.applicationを Ractor に渡してバックグラウンド処理等を行うようなユースケースでは、この PR によって「バックトレース整形で落ちる」類の問題が減る。- 独自の backtrace フィルタ/サイレンサーを多用しているアプリは、Ractor 対応する際に「どこまで shareable にするか」を意識して実装・設計する必要があります。
#57593 Add unit tests for ActiveStorage::Variation
マージ日: 2026/6/9 | 作成者: @Edilbek
- 概要 (1-2文で)
ActiveStorage のコア値オブジェクトであるActiveStorage::Variationに対して、専用のユニットテストが追加された PR です。既存では統合テスト越しにしか検証されていなかった振る舞いを、変換キーの扱いやエンコード/デコードの不変条件レベルで直接テストするようにしています。
- 変更内容の詳細
追加ファイル
activestorage/test/models/variation_test.rb(新規・111行)
このテストファイルで、ActiveStorage::Variation のパブリック API が網羅的にテストされています。主な観点は以下です。
2-1. 初期化: オプションの deep-symbolize
「Initialization: string keys are deep-symbolized」
ActiveStorage::Variation.new(transformations) にハッシュを渡したとき、{ "resize_to_limit" => ["100x100"] } のような「文字列キー」が「シンボルキー」に正規化されることをテストしています。
想定されるテスト例イメージ(擬似コード):
variation = ActiveStorage::Variation.new("resize_to_limit" => [100, 100])
assert_equal({ resize_to_limit: [100, 100] }, variation.transformations)- ネストがあるケースでも deep-symbolize されること(例:
{"resize" => {"width" => 100}}→{resize: {width: 100}})が確認されていると考えられます。 - これにより、呼び出し側がキーを string/symbol どちらで渡しても、内部的には同一の扱いとなることが保証されます。
2-2. wrap の振る舞い
「wrap: returns an existing instance unchanged, builds from a Hash, decodes a signed key」
ActiveStorage::Variation.wrap の 3 パターンをテストしています。
既に
Variationインスタンスの場合- そのまま返す(オブジェクトを変換・再構築しない)こと。
rubyvariation = ActiveStorage::Variation.new(resize_to_limit: [100, 100]) assert_same variation, ActiveStorage::Variation.wrap(variation)ハッシュからの生成
{ resize_to_limit: [100, 100] }や{ "resize_to_limit" => [100, 100] }からVariationインスタンスを作る。
rubyvariation = ActiveStorage::Variation.wrap("resize_to_limit" => [100, 100]) assert_instance_of ActiveStorage::Variation, variation署名付きキー(エンコード済み文字列)からの復元
ActiveStorage::Variation#encodeで生成した署名付きキー文字列を渡すと、同等の変換をもつVariationを復元できること。
rubyencoded = ActiveStorage::Variation.new(resize_to_limit: [100, 100]).encode variation = ActiveStorage::Variation.wrap(encoded) assert_equal({ resize_to_limit: [100, 100] }, variation.transformations)
2-3. encode / decode / key のラウンドトリップ
「encode / decode / key: round-trip transformations and consistent signed keys」
#encode: 変換ハッシュを署名付きトークン文字列に変換.decode/.wrap: トークン文字列を再度Variationに復元#key: 署名されたトークン(encodeの中身と整合するキー)を返す
などの API がラウンドトリップ可能であること、かつ同一の変換からは常に同じキーが得られることがテストされています。
例(イメージ):
variation = ActiveStorage::Variation.new(resize_to_limit: [100, 100])
encoded = variation.encode
decoded = ActiveStorage::Variation.decode(encoded)
assert_equal variation.transformations, decoded.transformations
# 同じ変換なら key は常に同じ
assert_equal variation.key, decoded.key2-4. キー/ダイジェストのパリティ (string/symbol)
「Key and digest parity: symbol-keyed and string-keyed transformations produce identical key and digest values」
同じ意味の変換でも、引数ハッシュのキーが string か symbol かに依存して異なるキー/ダイジェストにならないことを確認しています。
例(イメージ):
symbol_variation = ActiveStorage::Variation.new(resize_to_limit: [100, 100])
string_variation = ActiveStorage::Variation.new("resize_to_limit" => [100, 100])
assert_equal symbol_variation.key, string_variation.key
# digest (内部で key 生成に使っている値) も同じであることをテストこれにより、呼び出し側の表記揺れがキャッシュキーや URL の変化を招かないことが保証されます。
2-5. default_to の挙動
「default_to: fills missing transformations without overriding existing ones」
Variation#default_to(default_transformations) は、
- 変換が存在しないキーに対してはデフォルト値を埋める
- 既に変換が指定されているキーについては上書きしない
という振る舞いをテストしています。
例(イメージ):
variation = ActiveStorage::Variation.new(resize_to_limit: [100, 100])
variation.default_to(format: :png, resize_to_limit: [200, 200])
# 既存の値は維持
assert_equal [100, 100], variation.transformations[:resize_to_limit]
# 無かったキーだけデフォルトが入る
assert_equal :png, variation.transformations[:format]これにより、アプリ側で「既に指定されている変換を尊重しつつ、足りない変換だけ補う」ような拡張が安全にできるかが担保されています。
2-6. format / content_type の扱い
「format / content_type: defaults to PNG, accepts valid extensions, raises ArgumentError for invalid formats」
主に以下をテストしています。
デフォルトは PNG
formatを指定しない場合、#formatや#content_typeはimage/pngを前提とする。
rubyvariation = ActiveStorage::Variation.new assert_equal "image/png", variation.content_type有効な拡張子は受け入れ
format: :jpgやformat: "jpeg",format: "webp"など、サポート対象の拡張子なら#content_typeが正しい MIME type に解決される。
不正なフォーマットで ArgumentError
- 未知の拡張子(例:
format: :foo)を指定した場合にArgumentErrorを投げること。
rubyassert_raises(ArgumentError) do ActiveStorage::Variation.new(format: :foo).content_type end- 未知の拡張子(例:
これにより、アプリケーション側で誤った format 指定をしたときに、サイレントに失敗するのではなく、明確な例外で検知できることが保証されます。
2-7. transform はあえて未カバー
「transform is intentionally not tested here. That path depends on MiniMagick/Vips and is already covered in variant_test.rb.」
Variation#transform は実ファイル処理(MiniMagick / Vips)に依存するため、この PR ではテスト対象から外しています。
ここは既存の variant_test.rb など統合テストで既にカバーされているため、今回の PR はあくまで純粋な値オブジェクトとしての Variation の契約部分に特化しています。
- 影響範囲・注意点
- 本番コードへの変更は一切なし
- テストファイルの追加のみで、
ActiveStorage::Variationの挙動自体は変わっていません。
- テストファイルの追加のみで、
- ただし、テストによって挙動の「仕様」がより明文化されたと言えます。
そのため、今後以下のような変更を行う場合には、このテスト群が「仕様差分」を検知する役目を果たします:- 変換ハッシュの扱い(string/symbol の扱いなど)を変更する
encode/keyの生成ロジック(署名の中身や並び順など)を変更する- デフォルトの
formatやcontent_typeを変える
- ActiveStorage を利用して独自に
ActiveStorage::Variationを直接扱っているコードがある場合、- 本 PRによって壊れることはないものの、
- 既に行っていた利用パターン(特にフォーマットやキー生成に関する期待値)が、テストによって正式に「Rails 側の仕様」として固定化されたと見なせます。
- 参考情報 (あれば)
- 対象クラス:
ActiveStorage::Variation(activestorage/app/models/active_storage/variation.rb)
- 関連テスト(既存):
activestorage/test/models/variant_test.rb(transformを含む統合的な変換テスト)
- 利用される場面:
ActiveStorage::Variant(画像バリアント生成)- プレビュー / レプレゼンテーションコントローラ(
ActiveStorage::Variation.encodeを通じた署名付き URL など)
#57624 Fix URL fragment in Active Record Migrations guide [ci skip]
マージ日: 2026/6/8 | 作成者: @VladNegara
- 概要 (1-2文で)
Active Record Migrationsガイド内のリンク先フラグメント(ページ内アンカー)が誤っていたため、正しいフラグメントに修正したPRです。機能コードには一切手を触れず、ドキュメント上のリンク切れを解消するだけの変更です。
- 変更内容の詳細
対象:
guides/source/active_record_migrations.md
問題点:
- 「Creating a Join Table(結合テーブルの作成)」節から、Active Record Associationsガイドの「has_and_belongs_to_many」に飛ぶリンクが、存在しないフラグメント
#the-has-and-belongs-to-many-associationを指していた。
修正内容:
- フラグメントを以下のように変更:
- ...active_record_associations.html#the-has-and-belongs-to-many-association
+ ...active_record_associations.html#has-and-belongs-to-many意味としては同じセクションを指しているが、実際のHTML側のid属性が has-and-belongs-to-many で定義されているため、それに合わせてリンクを修正した、という内容です。
コードサンプル上の挙動やマイグレーションDSLなどには一切変更はなく、「どのセクション説明に飛ぶか」というドキュメント内ナビゲーションだけが変わっています。
- 影響範囲・注意点
影響範囲
- Railsガイド(Active Record Migrations -> Creating a Join Table)のページ内リンクのみ。
- ランタイムの挙動、API、テストには影響なし。
- 既存コード、アプリケーション、gemとの互換性に影響なし。
注意点
- ドキュメントをローカルでビルド・ホストしている場合も、ビルド済みHTMLのid属性が
has-and-belongs-to-manyである前提なので、ガイド生成側のレイアウトが大きく変更されていない限り、この変更によりリンク切れが解消されます。 - 外部からこの古いフラグメント
#the-has-and-belongs-to-many-associationを直リンクしていた場合は、引き続き404相当の挙動(スクロールされない)になりますが、これはRailsガイド外部の問題です。
- ドキュメントをローカルでビルド・ホストしている場合も、ビルド済みHTMLのid属性が
- 参考情報 (あれば)
- 該当ガイド:
- Active Record Migrations: 「Creating a Join Table」節
- Active Record Associations: 「Has and Belongs to Many」節(id:
has-and-belongs-to-many)
- 類似の修正はドキュメントのリンク切れ対応としてよく行われており、本PRもそれと同種のメンテナンス変更です。
#57620 Fix number_to_delimited corrupting numbers with a leading sign
マージ日: 2026/6/8 | 作成者: @55728
- 概要 (1-2文で)
number_to_delimitedが符号付き数値(先頭に-や+が付く数)を区切り文字付きに変換する際、先頭の符号直後に区切り文字を挿入してしまう不具合を修正した PR です。これにより、number_to_roundedやnumber_to_humanで符号付き数値+delimiter:を使った場合の誤ったフォーマットも合わせて解消されます。
- 変更内容の詳細
不具合の内容
number_to_delimited に負数(または + 付きの文字列)を渡すと、桁数が 3 の倍数の場合に符号の直後にカンマが入り、数値が壊れていました。
number_to_delimited(-123456) # 以前: "-,123,456" 本来: "-123,456"
number_to_delimited(-123) # 以前: "-,123" 本来: "-123"
number_to_delimited(+123456) # 以前: "+,123,456" 本来: "+123,456"
number_to_delimited("+123") # 以前: "+,123" 本来: "+123"number_to_rounded や number_to_human も内部で同じ変換ロジックを使うため、delimiter: オプションを指定すると同様に壊れた出力になっていました。
number_to_rounded(-123456, delimiter: ",", precision: 0)
# 以前: "-,123,456" 本来: "-123,456"
number_to_human(-123456, delimiter: ",")
# 以前: "-,123 Thousand" 本来: "-123 Thousand"原因
NumberToDelimitedConverter#parts の「高速パス」が、整数部を 3 桁ごとに分割する際に offset = left.size % 3 を使っていました。このとき、left に符号 - / + も含まれていたため、「符号を 1 桁として扱ってしまい、そこでグループを切る」という状態になっていました。
例: -123456
- integer 部
leftは"-123456" left.size == 7→offset = 7 % 3 == 1- 先頭 1 文字とそれ以降 3 桁ずつでグループ化される
→["-", "123", "456"].join(",")→"-,123,456"
-1234 など、一見正しく見えるケースは「たまたま符号と1桁目が同じグループになっている」だけで、ロジックとしては常に壊れている状態です。
元々は delimiter_pattern の正規表現 /(\d)(?=(\d\d\d)+(?!\d))/ を使う旧パスがデフォルトで、これは数字にしかマッチしないため符号を誤処理しませんでした。
しかし、コミット 33fbedb1b1 で % 3 を使う高速パスがデフォルトになった際に、この符号処理の差分が考慮されず、回 regress しました。
同じ高速パスが原因で Float::INFINITY が "In,fin,ity" になる問題は、以前のコミット 150e4c0443 で修正済みであり、今回は「符号付き数値版の同じ問題」を修正しています。
修正内容
整数部のグルーピング前に、先頭の符号をいったん切り離し、グルーピング後に付け直すようにしました。NumberToCurrencyConverter が「絶対値をフォーマットしてから符号を再適用する」のと同じ考え方です。
該当箇所(概略):
# 変更前(イメージ)
left = integer_part
# left をそのまま offset / 3 桁グループ化
# 変更後(イメージ)
left = integer_part
sign = left.slice!(0) if left.start_with?("-", "+") # ← 符号を取り除く
# ここから先は「符号を抜いた left」を 3 桁ずつに分割する既存処理
left_parts = []
offset = left.size % 3
left_parts << left.slice!(0, offset) if offset > 0
left_parts.concat(left.scan(/.{3}/))
# 最後に符号を戻す
left = "#{sign}#{left_parts.join(options[:delimiter])}"ポイント:
- 変更は
#parts内に限定されており、delimiter_pattern正規表現を使うパスには影響しません。 +付きの文字列入力("+123456"など)も対象になり、同様に修正されます。
テスト追加
activesupport/test/number_helper_test.rb に以下のテストが追加されています。
test_to_delimited_with_negative_numbers- 対象:
-1,-12,-123,-1234,-123456,-123456789,-123456.78など - 文字列形式の負数もカバー
- 対象:
test_to_delimited_with_leading_plus_sign- 対象:
"+123","+1234","+123456","+123456.78"
- 対象:
もともと number_to_delimited に対して符号付き数値のテストが存在しておらず、そのため regression が検出できなかった、という背景も明示されています。
- 影響範囲・注意点
- 影響を受けるメソッド:
number_to_delimitednumber_to_rounded(delimiter:オプション使用時)number_to_human(delimiter:オプション使用時)
- 修正内容により、「これまで壊れたフォーマットが出ていたケース」が正しいフォーマットになります。
- 例: 既存のテスト・期待値・スナップショット等で
"-,123,456"のような誤った値をあえて使用していた場合は、テストが落ちる可能性があります。
- 例: 既存のテスト・期待値・スナップショット等で
- 正の数、ゼロ、符号なしの文字列、浮動小数、
delimiter_patternをカスタムしたケースは、今回の修正では動作が変わりません。 number_to_currencyはすでに「絶対値+符号の再適用」という設計であり、今回の修正の対象ではないことが確認されています。
実運用上は「いままで静かに壊れていた値が正しくなる」だけなので、多くの場合は歓迎される変更ですが、帳票やログで「文字列としてのフォーマットが変わる」ことには注意してください。
- 参考情報 (あれば)
関連する過去の変更:
33fbedb1b1—NumberToDelimitedConverterの高速パスをデフォルトにした変更150e4c0443— 非有限浮動小数点 (Float::INFINITYなど) に対する同じ経路の不具合修正
実際の影響確認のためのサンプルコード(コンソール用):
rubyinclude ActionView::Helpers::NumberHelper [ -123, -1234, -123456, -123456.78, "+123", "+1234", "+123456", "+123456.78" ].each do |n| puts "#{n.inspect.ljust(12)} => #{number_to_delimited(n)}" end puts number_to_rounded(-123456, delimiter: ",", precision: 0) puts number_to_human(-123456, delimiter: ",")
#57621 Fix InheritableOptions#== raising NoMethodError on non-Hash comparison
マージ日: 2026/6/8 | 作成者: @55728
- 概要 (1–2文で)
ActiveSupport::InheritableOptions#==が Hash 以外との比較時にNoMethodErrorを起こしていた問題を修正し、Hash/Hashライクなオブジェクトとのみ中身を比較し、それ以外とは常にfalseを返すようにした PR です。これによりnilと空のInheritableOptionsが誤って等価になる問題や、Array#include?などからの予期せぬNoMethodErrorが解消されます。
- 変更内容の詳細
問題のあった既存実装
ActiveSupport::InheritableOptions#== は以下のように実装されていました。
def ==(other)
to_h == other.to_h
endこのため、other が to_h を持たないオブジェクト(String, Integer, Symbol など)の場合に NoMethodError が発生していました。
options = ActiveSupport::InheritableOptions.new(one: "first value")
options == "first value"
# => NoMethodError: undefined method `to_h` for "first value":String
[options].include?("x")
# => Array#include? は == を使うので、同様に NoMethodErrorさらに、nil.to_h # => {} であることから、空の InheritableOptions が nil と等価になってしまうバグもありました。
ActiveSupport::InheritableOptions.new == nil
# => true (本来は false であるべき)修正後の実装
#== を次のように変更しています:
def ==(other)
other.is_a?(Hash) && to_h == other.to_h
endポイント:
respond_to?(:to_h)ではなく、is_a?(Hash)を使っているnilはto_hを持つ(nil.to_h # => {})ため、respond_to?ベースだとnilと空のInheritableOptionsが再び等価になってしまうHash#==の振る舞いと同じにしたい、という意図
Hashのサブクラス(ActiveSupport::OrderedOptionsなど)や他のInheritableOptionsはis_a?(Hash)を満たすので、これらとは中身をto_hで比較する- それ以外のオブジェクトとは、常に
falseを返す
修正後の挙動サンプル
options = ActiveSupport::InheritableOptions.new(one: "first value")
options == { one: "first value" }
# => true
options == ActiveSupport::InheritableOptions.new(one: "first value")
# => true(マージ済みの中身を比較)
options == { one: "other value" }
# => false
options == "first value"
# => false
options == 42
# => false
options == nil
# => false
[options].include?("x")
# => false(例外は出ない)テスト
activesupport/test/ordered_options_test.rb に以下のようなテストが追加されています(要旨):
test_inheritable_options_equalityInheritableOptions同士やHashとの比較が正しく true/false を返すか
test_inheritable_options_equality_with_non_hash_returns_falseString, 数値,nilなど Hash ではないものとの比較がfalseになり、例外を出さないこと
- 影響範囲・注意点
- 影響するクラス/機能
ActiveSupport::InheritableOptions- Rails credentials (
Rails.application.credentials) AbstractControllerのconfig- Active Record の
protocol_adapters - Action Text のエディタ設定
- など、
InheritableOptionsを内部実装に使っている箇所全般
- Rails credentials (
- 行動の変化
- 以前は「誤って
NoMethodErrorが飛ぶ」「空のInheritableOptionsがnilと等しい」という明らかなバグだったため、この変更により壊れる正当なユースケースは基本的に想定されていません。 - もしアプリケーション側で、
- 「
InheritableOptionsとnilが等価になる」ことに暗黙に依存していた - あるいは「非 Hash オブジェクトとの比較で例外が起きる」ことを前提にテストを書いていた
といったケースがあれば、その挙動は変わります(falseを返すようになる)。
- 「
- 以前は「誤って
Hash#==相当の振る舞いになるため、OrderedOptionsなど他の Hash ベースの設定オブジェクトと一貫性が取れます。
- 参考情報 (あれば)
- 類似クラス:
ActiveSupport::OrderedOptionsはHashを継承しており、Hash#==をそのまま使っているため、もともと非 Hash 比較時はfalseを返していました。本 PR によりInheritableOptionsもこれに揃えられています。 - もとの変更:
InheritableOptions#==自体は 81d0a29 ("ImproveInheritableOptionshash-like behaviour") で導入された機能で、親とマージ済みの内容で比較を行うために実装されましたが、その際に「非 Hash 相手」のケースが考慮されていませんでした。
#57615 Fix clear_*_change doc claiming it clears previous changes [ci skip]
マージ日: 2026/6/7 | 作成者: @55728
- 概要 (1-2文で)
clear_*_changeメソッド(正確にはclear_attribute_changeが生成する系)のドキュメントが「現在の変更と過去の変更を両方クリアする」と誤って説明していたのを、「現在の変更だけをクリアする」という実装どおりの内容に修正する PR です。挙動自体には変更はなく、ドキュメントのみの修正です。
- 変更内容の詳細
対象:
- ActiveModel::Dirty が生成する
clear_*_changeメソッド (clear_attribute_change) に対するドキュメントコメント。
- ActiveModel::Dirty が生成する
実装とドキュメントの乖離:
ドキュメントには以下のような趣旨が書かれていました:
属性の dirty データ(現在の変更と過去の変更)をすべてクリアする
しかし実際の実装は次のとおりで、現在の変更のみを消しており、過去の変更 (
previous_changes/saved_changes) には触れていません:rubydef clear_attribute_change(attr_name) mutations_from_database.forget_change(attr_name.to_s) endmutations_from_databaseは「現在の変更」を表すトラッカーで、previous_changes/saved_changesはmutations_before_last_saveという別トラッカーにより管理されています。clear_attribute_changeはこのmutations_before_last_saveには一切触れないため、「過去の変更もクリアする」という説明は誤りです。
正しい「両方クリア」するメソッド:
- 現在の変更と保存前の変更の両方をリセットするのは
clear_changes_informationであり、clear_attribute_changeではありません。
- 現在の変更と保存前の変更の両方をリセットするのは
具体的な修正:
- 該当ドキュメント中の「and previous changes」という文言を削除し、「現在の変更をクリアする」ことのみを説明するように 1 行差し替えています(+1/-1)。
- メソッドの使用例自体はもともと「現在の変更だけが消える」ケースを示しており、実装と整合していました。今回の修正で、ドキュメントの説明文もそれに揃えています。
経緯:
- この「and previous changes」という文言は、過去のコミット
ee58c8e6beで誤って導入されたとのことです。
- この「and previous changes」という文言は、過去のコミット
- 影響範囲・注意点
- 挙動の変更は一切ありません。
- 既存の
clear_*_change/clear_attribute_changeの動作(現在の変更のみクリア)は変わりません。
- 既存の
- 影響するのは以下のようなケースです:
- ドキュメントを読んで「
clear_*_changeを呼べばprevious_changes/saved_changesも消える」と誤解していたコードレビューや設計判断。 - その前提で「過去の変更がクリアされているはず」と想定しているテストや仕様書(※実際のコードはもともと過去の変更を消していないため、挙動と期待がずれていた可能性があります)。
- ドキュメントを読んで「
- 実際にやりたいこと別の正しいメソッド選択:
- 特定属性の「現在の変更」だけクリアしたい:ruby
user.clear_attribute_change(:name) # または generated method: user.clear_name_change - 全属性の現在&保存前の変更情報を一括でクリアしたい:ruby
user.clear_changes_information
- 特定属性の「現在の変更」だけクリアしたい:
- 注意点:
- 「保存後に
previous_changesを消したい」といった用途でclear_*_changeに頼っていた場合、もともとその用途には合っていなかったため、改めて要件に応じてclear_changes_informationを使う- あるいは別に状態を保持する といった見直しが必要です。
- 「保存後に
- 参考情報 (あれば)
- 関連 API:
ActiveModel::Dirty#changes/changed_attributesActiveModel::Dirty#previous_changes/saved_changesActiveModel::Dirty#clear_attribute_changes(attr_name)(生成されるclear_*_change系)ActiveModel::Dirty#clear_changes_information
- 実装上のトラッカー:
- 現在の変更:
mutations_from_database - 最後の保存前の変更:
mutations_before_last_save
→ 今回の PR は、これらの役割分担にドキュメントを合わせた、という位置づけです。
- 現在の変更:
#57598 Fix find with an explicit order + offset past the ids raising a spurious RecordNotFound
マージ日: 2026/6/7 | 作成者: @55728
- 概要 (1-2文で)
findに配列ID + 明示的なorder+offsetが ID 数を超える値で指定された場合に、意味不明な負数付きのActiveRecord::RecordNotFoundが発生していた不具合を修正し、そのケースで空配列 ([]) を返すように揃えた PR です。あわせて、orderの有無でfindの挙動が食い違わないことを確認する回帰テストが追加されています。
- 変更内容の詳細
何が問題だったか
次のようなクエリを考えます:
Model.order(:id).offset(10).find([1, 2, 3])ids = [1, 2, 3] で offset = 10 のように、「オフセットが ID 配列のサイズを超えている」場合、本来であれば単に「該当レコードなし」として [] が返ってきてほしいところです。
実際、order を付けない場合は正しく [] を返します:
Model.offset(10).find([1, 2, 3])
# => []しかし、order を付けると以下のような例外が発生していました:
Model.order(:id).offset(10).find([1, 2, 3])
# => ActiveRecord::RecordNotFound:
# Couldn't find all Models with 'id': (1, 2, 3)
# (found 0 results, but was looking for -7).found 0 results, but was looking for -7 という負数が出ている時点で、内部の期待件数の計算が破綻していることがわかります。
原因 (find_some / find_some_ordered の違い)
ActiveRecord::FinderMethods#find は、呼び出し条件によって内部的に次の2つの経路に分岐します:
- 明示的な
orderあり:find_some経由 - 明示的な
orderなし:find_some_ordered経由
問題のロジックは find_some の中にあり、「期待される件数 (expected_size)」を offset と limit を使って算出する処理でバグがありました。
該当するコードイメージは概ね以下のようなものです:
# 例: 11個のid、limit=3、offset=9 の場合、結果は2件になるべき
if offset_value && (ids.size - offset_value < expected_size)
expected_size = ids.size - offset_value
endoffset_value が ids.size より大きい(=配列の末尾を通り過ぎている)とき、
ids.size - offset_value # => 負の数となり、そのまま expected_size に代入されてしまいます。
結果として:
- 実際の取得件数:
result.size # => 0 - 期待件数:
expected_size # => 負の数
となるため、「期待件数と実際の件数が違う」と判断され、RecordNotFound が投げられていました。
一方で、order なしパス(find_some_ordered)では、offset / limit で ID をスライスしているため、同じ条件でも単に [] を返し、この不具合は発生していませんでした。
つまり、「order 有り」の経路だけが異常に厳しく、かつ壊れている 状態でした。
修正内容
expected_size をオフセットで調整する際、負数にならないよう 0 でクランプしています:
expected_size = [ids.size - offset_value, 0].maxこれにより:
ids.size - offset_valueが 0 以上 → そのままids.size - offset_valueが負数 → 0 にする
という挙動になります。
具体例:
- 通常のパターン(例:
ids.size = 11, limit = 3, offset = 9)ids.size - offset = 2→max(2, 0) == 2→ 従来どおり、2件を期待 - バグっていたパターン(例:
ids.size = 3, offset = 10)ids.size - offset = -7→max(-7, 0) == 0
→ 期待件数 0、実際のresult.sizeも 0 なので、例外を出さずに[]を返す
これで、「order ありパス」も「order なしパス」と同じように、オフセットが範囲外に飛び出したときは単に空結果を返すようになります。
テスト追加
activerecord/test/cases/finder_test.rb に回帰テストが2つ追加されています。
test_find_with_order_and_offset_past_the_ids_returns_emptyoffsetが ID 数を超えるケース、およびちょうど等しい境界 (offset == ids.size) で、orderありの経路 (find_some)orderなしの経路 (find_some_ordered) が共に[]を返すことを検証。
test_find_with_order_limit_and_offset_matches_unordered_path- 11件の Developer fixture を使って、7パターンの
limit/offset組み合わせを総当たりし、 orderあり(find_some)とorderなし(find_some_ordered)で返ってくるレコード集合が常に一致することを確認。- 範囲内のオフセット、終端を超える
limit、offset == ids.size、offset > ids.sizeなどを網羅。
- 11件の Developer fixture を使って、7パターンの
テストは sqlite3 / PostgreSQL / MySQL2 の3アダプタで「失敗を再現 → 修正後にパス」を確認しています。
- 影響範囲・注意点
影響を受けるパターン
Model.order(...).offset(X).find([array_of_ids])のようにfindに「配列ID」を渡している- 明示的な
orderを指定している - そのクエリに対する
offsetが「渡している ID 配列の要素数より大きい」
- これまで:
ActiveRecord::RecordNotFound例外が発生していた - 修正後:
[]が返る(order無しパスと揃う)
互換性・挙動変更
- 変更されるのは「これまでそもそも壊れていた・実務上も不自然」なケースのみです。
- 正常な範囲での
offset/limitの挙動、offset == ids.sizeの境界ケースは従来どおり(※offset == ids.sizeに関しては元々両パスとも[]を返していた)。 - もし既存コードが「この壊れた挙動に(=負の expected size による RecordNotFound 発生)依存している」場合は振る舞いが変わりますが、通常は依存すべきでない仕様外のバグなので、修正によるリスクは小さいと考えられます。
実務への影響
- ページネーション実装などで、ユーザーが「最終ページを超えたページ番号」を指定した場合に、
orderの有無でエラーになったりならなかったりする不整合があったのが、[]に統一されます。 - 「配列 ID + 明示的 order + 大きい offset」を組み合わせているコードで、これまで謎の
RecordNotFoundが sporadic に出ていたなら、この修正で改善される可能性があります。
- ページネーション実装などで、ユーザーが「最終ページを超えたページ番号」を指定した場合に、
- 参考情報 (あれば)
対象ファイル
activerecord/lib/active_record/relation/finder_methods.rbfind_some内のexpected_size計算ロジックが修正。
activerecord/test/cases/finder_test.rbfindのorder/limit/offset周りの回帰テストが追加。
挙動変更の要点
- 「offset > ids.size」での
findは、orderの有無に関わらず[]を返す、という揃った仕様になった。 - 内部的には「期待件数を負数にしない(0でクランプする)」というシンプルなバグ修正。
- 「offset > ids.size」での
#57613 Make rails new work again on systems that do not have vips
マージ日: 2026/6/7 | 作成者: @jeromedalbert
- 概要 (1-2文で)
rails new実行時に vips がインストールされていない環境でアプリ作成が失敗していた不具合を修正し、Active Storage で vips を実際に使うタイミングまでエラーが発生しないようにした PR です。Gemfileのruby-vipsの読み込み方法を変え、テストも追加されています。
- 変更内容の詳細
Gemfile テンプレートの修正
rails new で生成されるアプリの Gemfile のテンプレート (Gemfile.tt) が修正されています。
変更前(イメージ):
gem "ruby-vips"変更後:
gem "ruby-vips", require: falseポイント:
require: falseを付けることで、Bundler がアプリ起動時に自動でrequire "vips"しなくなります。- これにより、システムに vips ライブラリや関連ネイティブ拡張が無くても、アプリのブート時点ではエラーになりません。
- 実際に vips を必要とするのは Active Storage が vips をバックエンドとして利用するタイミングなので、その時点で vips または ImageMagick が入っていることを前提にしています。
テストの追加
railties/test/generators/app_generator_test.rb にテストが追加されています。
意図されていること:
- vips がインストールされていない環境でも
rails newで生成されたアプリが正常に起動できることを担保する。 - Gemfile に
ruby-vipsが含まれていても、require: falseのおかげで即時にロードされず、アプリ起動が壊れないことを確認する。
(実際のテストコードは PR 本文には出ていませんが、Gemfile.tt の出力と生成アプリの挙動を検証していると考えられます。)
- 影響範囲・注意点
影響対象:
- Rails
mainブランチ(将来のバージョン)でrails newを実行するすべての開発者。 - 特にローカルに vips をインストールしていない開発環境。
- Rails
ポジティブな影響:
- vips 未インストール環境で
rails newが落ちるというレグレッションが解消されます。 - Active Storage を使わない(あるいは画像処理を使わない)アプリでも、vips がないことが原因で起動できない、といった不具合がなくなります。
- vips 未インストール環境で
注意点:
require: falseは「自動で require しない」だけであり、vips 自体が不要になるわけではありません。- Active Storage で vips ベースの画像変換を行いたい場合は、引き続き:
- システムに vips (libvips) をインストールする
ruby-vipsgem を bundle install する
といったセットアップが必要です。
- 画像処理を実行するタイミングで、vips(あるいは代替として ImageMagick + mini_magickなど)が正しくインストールされていないと、その時点でエラーになります。
- 参考情報 (あれば)
- 元 PR: https://github.com/rails/rails/pull/57403
- この PR で修正された issue: https://github.com/rails/rails/issues/57612
ruby-vipsgem: https://github.com/libvips/ruby-vips- Bundler の
require: falseオプションの説明(Bundler ドキュメント):
https://bundler.io/guides/groups.html#require-false
#57616 Fix ActiveModel::Type::Decimal doc for a non-numeric cast [ci skip]
マージ日: 2026/6/7 | 作成者: @55728
- 概要 (1-2文で)
ActiveModel::Type::Decimalのドキュメント中のキャスト結果の説明が実装と食い違っていたため、非数値を渡した場合の戻り値をnilではなく0.0に修正するドキュメント更新です。実装自体の挙動変更はなく、コメント(サンプルコードの期待値)のみが正されました。
- 変更内容の詳細
対象: activemodel/lib/active_model/type/decimal.rb のドキュメント例 (コメント)
元のドキュメントでは、以下のような説明になっていました (意図としてはこういう内容だった):
bag.weight = :arbitrary
bag.weight # => nil (the result of `.to_s.to_d`)しかし、実際の Ruby / BigDecimal の挙動は次の通りです:
:arbitrary.to_s # => "arbitrary"
"arbitrary".to_d # => 0.0 # 非数値文字列は 0.0 になるRails の実装もこれに沿っており:
ActiveModel::Type::Decimal.new.cast(:arbitrary) # => 0.0
ActiveModel::Type::Decimal.new.cast("") # => nil # 空文字列のみ nilこの PR では、ドキュメント内の期待値コメントを以下のように修正しています:
bag.weight = :arbitrary
bag.weight # => 0.0 (the result of `.to_s.to_d`)※ コード本体のロジックは一切変更されておらず、1行のコメント(例の戻り値)を nil → 0.0 に変更しただけです。
- 影響範囲・注意点
影響範囲
- 実行時挙動の変更はなく、既存アプリケーションの動作には一切影響しません。
- Rails ガイド/ソース内コメントを読んでいた人が、
「非数値をDecimalにキャストするとnilになる」と誤解してしまう可能性が解消されます。
注意点 (開発者視点での挙動整理)
ActiveModel::Type::Decimalの#castは概ねvalue.to_s.to_d相当の挙動であり、「空文字列」はnil、それ以外の非数値文字列は0.0になります。- 例:ruby
type = ActiveModel::Type::Decimal.new type.cast("") # => nil type.cast(" ") # => nil (blank? 判定で弾かれる) type.cast(nil) # => nil type.cast("abc") # => 0.0 type.cast(:symbol) # => 0.0 type.cast("123.45") # => 0.12345e3 のような BigDecimal
- 例:
- 「非数値は
nilになってほしい」という前提でバリデーションや分岐を書いていると、
実際には0.0が入っていて不具合になる可能性があります。
その場合は- カスタムの型を定義する
- 事前に値を検査して弾く などで対処する必要があります。
- 参考情報 (あれば)
- BigDecimal の挙動 (Ruby 本体):
BigDecimal("abc")は0.0を返す仕様です。
- 関連する Rails の実装:
ActiveModel::Type::DecimalはActiveModel::Type::Valueを継承し、cast内でvalue.to_sした上でBigDecimal変換を行うことで、この挙動に従っています。
#57618 Fix add_index example in Active Record Schema docs
マージ日: 2026/6/7 | 作成者: @55728
- 概要 (1-2文で)
ActiveRecord::Schema のドキュメント内で示されていたadd_indexの使用例が実際のメソッドシグネチャと合っておらず、実行するとArgumentErrorになる問題を修正した PR です。add_indexに対する誤った位置引数の利用例を、正しいキーワード引数形式へと修正しています。
- 変更内容の詳細
対象ファイル:
activerecord/lib/active_record/schema.rb(1行の修正)
元のドキュメント例では、以下のように add_index が呼ばれていました:
ActiveRecord::Schema[7.0].define do
create_table :authors do |t|
t.string :name, null: false
end
add_index :authors, :name, :unique
endしかし、add_index は以下のように定義されており:
def add_index(table_name, column_name, **options)
# ...
end第3引数以降はキーワード引数(**options)として受け取るため、:unique のような裸のシンボルを位置引数として渡すと ArgumentError: wrong number of arguments になります。
この PR では、この例を正しくキーワード引数を使う形に修正しています:
ActiveRecord::Schema[7.0].define do
create_table :authors do |t|
t.string :name, null: false
end
add_index :authors, :name, unique: true
endつまり、:unique → unique: true への変更のみが行われています。
- 影響範囲・注意点
影響範囲
- 変更はドキュメント(サンプルコード)上の 1 行のみで、ライブラリの挙動そのもの(
add_indexの実装)には変更がありません。 - 既存アプリの実行時挙動には影響しません。
- 変更はドキュメント(サンプルコード)上の 1 行のみで、ライブラリの挙動そのもの(
注意点
- もし自分のアプリでドキュメントをコピーして同様に
add_index :authors, :name, :uniqueと書いている場合は、必ずadd_index :authors, :name, unique: trueのようにキーワード引数形式に修正する必要があります。 - 他のオプション(
name:,where:,using:など)もすべてキーワード引数で渡す前提になっているため、add_indexにシンボルなどを位置引数として追加する形はサポートされていません。
- もし自分のアプリでドキュメントをコピーして同様に
- 参考情報 (あれば)
add_indexの定義:activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rbrubydef add_index(table_name, column_name, **options) # ... end- 正しい呼び出し例:ruby
add_index :authors, :name, unique: true add_index :posts, [:user_id, :created_at], name: "index_posts_on_user_and_created_at"
#57607 Fix in_order_of surfacing NULL rows for an unknown enum key or out-of-range integer
マージ日: 2026/6/7 | 作成者: @55728
- 概要 (1-2文で)
ActiveRecord::Relation#in_order_ofが、列挙型(enum)の未知キーや範囲外整数を指定したときに、本来無視すべき値をNULLマッチとして扱い、NULL行を紛れ込ませてしまう不具合を修正した PR です。明示的なnilと「シリアライズ結果としてのnil」を区別し、後者はin_order_ofから除外することで、Enumerable#in_order_ofと同じ挙動に揃えています。
- 変更内容の詳細
不具合の内容
ActiveRecord::Relation#in_order_of(column, values) で、values に以下のような値が含まれる場合に問題が起きていました。
- enum カラムに対する「未知のキー」
- 整数カラムに対する「範囲外の整数」
例:
# books: one :proposed, one :written, one with a NULL status
Book.in_order_of(:status, [:written, :bogus, :proposed]).pluck(:id)
# 実際の挙動 (バグあり):
# => [2, 3, 1] # :bogus の位置に NULL 行(id 3)が割り込む
# 期待される挙動:
# => [2, 1] # :bogus は無視されるべきEnumerable#in_order_of は「コレクション内に存在しない値」は素通りして無視する仕様であり、Active Record 版も「存在しない値は無視する」と CHANGELOG で明記されていましたが、実装上は次のような経路で NULL 行を拾っていました。
in_order_ofは、valuesの各要素についてカラム型に合わせてシリアライズする:rubycaster.serialize(value) if caster.serializable?(value)その結果:
- 「範囲外整数」:
serializable?が false →ifの結果がnil - 「未知 enum キー」:
serializable?は true だがserializeがnilを返す
- 「範囲外整数」:
すると、「元々 nil ではないが、結果として
nilになった値」と「呼び出し側が明示的に渡したnil」が区別できなくなる。in_order_ofは、呼び出し側がnilを指定した場合はcolumn IS NULLをマッチさせる仕様のため、
シリアライズに失敗した値も「nilとみなして良い」と誤解され、WHERE column IN (…) OR column IS NULLORDER BY CASE WHEN column = … THEN … WHEN column IS NULL THEN … ENDのようにNULL行を拾う・並び替えるロジックに紛れ込んでしまう、というバグでした。
既存テスト test_in_order_of_with_out_of_bound_integer では Post.id(NOT NULL な主キー)を使っていたため、「余計な IS NULL 条件」があっても実際にマッチする行がなく、バグが表に出ていませんでした。
修正方針
修正のポイントは以下です。
明示的な
nilと、シリアライズ結果としてのnilを区別する- 呼び出し側が
valuesにnilを入れた場合:
→ 従来通り、NULL行をマッチさせる(column IS NULLを使う)。 - 呼び出し側が
nil以外の値を指定したが、serializable?が false だったりserializeがnilを返した場合:
→ その値はin_order_ofの対象から完全に除外する(WHEREにもORDER BY CASEにも出さない)。
- 呼び出し側が
値のドロップ後に
valuesが空になりうることへの対応- すべての値が「非シリアライズ可能 or シリアライズ結果が
nil」だった場合、
ロジック上CASE式のWHEN句が 1 つも無いCASE END(またはCASE ELSE 1 ENDforfilter: false)が生成される可能性があり、これは SQL 的に不正でStatementInvalidを引き起こします。 - 今回の変更では、「ドロップ処理の結果として
valuesが空になった場合」は、呼び出し元が最初から[]を渡した場合と同様にnone!(空 Relation)を返すようにしました。
これにより、不正なCASE式は組み立てられず、クエリ実行時にエラーにもならず、シンプルに「0件」が返ります。
- すべての値が「非シリアライズ可能 or シリアライズ結果が
配列グルーピング分岐でも同じ扱い
in_order_ofには、単純な値列だけでなく「値をグルーピングして扱う」ブランチ(配列をまとめて 1 グループ扱いするようなケース)もありますが、そちらでも同様に「シリアライズできない・nilになってしまう値はドロップする」ように揃えています。- ドロップされた値は
WHEREにもORDER BYにも一切寄与しません。
以上により、Enum の未知キーや範囲外整数は Enumerable#in_order_of と同じく「存在しないものとして無視」され、かつ余計な NULL 行のマッチングも発生しなくなります。
追加・修正されたテスト
activerecord/test/cases/relation/field_ordered_values_test.rb に以下のテストが追加されています。
test_in_order_of_with_unknown_enum_key_does_not_match_nulls- 対象:
Book#nullable_status(NULL 許可の enum カラム) - 状況:
status = nilの行を含むテーブルに対して、未知 enum キーをvaluesに含めてin_order_ofを実行。 - 確認内容: 未知キーは無視され、
NULL行は拾われないこと。
- 対象:
test_in_order_of_with_out_of_bound_integer_does_not_match_nulls- 対象:
Book#format_record_id(NULL 許可の整数カラム) - 状況:
format_record_id = nilの行がある中で、範囲外の整数をvaluesに指定。 - 確認内容: 範囲外整数は無視され、
NULL行は拾われないこと。
(主キーではなく NULL 許可カラムを使うことで、これまで見落としていたバグをきちんと検証)
- 対象:
test_in_order_of_with_only_unrepresentable_values_does_not_build_empty_case- 状況: 渡した全ての値がシリアライズ不可能で、結果としてすべてドロップされるケース。
- 確認内容:
filter: true/filter: falseいずれの場合でもStatementInvalidが発生しないこと。- 結果 Relation は空(
none!相当)になること。
なお、既存の test_in_order_of_with_nil などにより、「明示的に nil を指定した場合に NULL 行をマッチさせる」従来仕様は維持されていることも確認されています。
- 影響範囲・注意点
仕様として期待されていた動き(
Enumerable#in_order_ofと同様、「存在しない値は無視」)に合わせる修正であり、ドキュメントおよび以前の CHANGELOG の意図を「復元」する変更です。ただし、これまでのバグに依存していたコード(「未知 enum キーや範囲外整数を渡すと
NULL行が取れる」挙動をあえて利用していたケース)があれば、挙動は変わります。- 今後はそのような値は単に無視されるため、
NULL行を取りたい場合は、明示的にnilを値リストに含める必要があります:ruby# NULL を明示的にマッチさせたい場合 Book.in_order_of(:status, [:written, nil, :proposed])
- 今後はそのような値は単に無視されるため、
「渡した値がすべてシリアライズできない」ケースでは、これまでは(実際には到達しづらかったものの)DBエラー
StatementInvalidになりえたところ、今後は単に「0件を返す Relation」になります。- これにより、
in_order_ofの結果をそのままスコープとして合成・チェインしても DB 例外で落ちることはなくなります。
- これにより、
この変更は次の全サポート DB アダプタで確認されています:
- sqlite3
- postgresql
- mysql2
field_ordered_values_test, enum_test, relations_test も一通りグリーンで、他の機能に対する副作用は抑えられています。
- 参考情報 (あれば)
- 対象 PR: https://github.com/rails/rails/pull/57607
- 過去の関連 PR(範囲外値の扱いを導入したもの): #44746
- ここで「範囲外値は無視する」とドキュメント化されていたものの、実装は
NULL行を拾うバグを含んでいました。本 PR でドキュメントと実装が一致しました。
- ここで「範囲外値は無視する」とドキュメント化されていたものの、実装は
- 関連するメソッド仕様:
Enumerable#in_order_of(Ruby 標準): 集合に存在しない値は単に結果に現れない。- ActiveRecord 側も、それに忠実に合わせる方針です。
#57611 Fix double encode for Coders::JSON and JSON column
マージ日: 2026/6/6 | 作成者: @skipkayhil
- 概要 (1-2文で)
ActiveRecordで、ネイティブのjson/jsonbカラムに対してserializeを JSON コーダー付きで宣言したときに、ActiveRecord::Coders::JSON経由だと二重エンコードされて壊れた JSON が保存される不具合を修正したPRです。JSONとActiveRecord::Coders::JSONなどJSONコーダーのバリエーションを、互換性チェックですべて正しく検知するようにしました。
- 変更内容の詳細
問題の背景
Rails では、以下の2つは事実上同じ「JSONコーダー」として扱われます。
serialize :data, coder: JSON
serialize :data, coder: ActiveRecord::Coders::JSONbuild_column_serializer は内部的にこれらを同じ JSON コーダーとして「正規化」して扱っていますが、型との互換性を確認するガード (type_incompatible_with_serialize?) は、「素の ::JSON 定数」にしかマッチしていませんでした。
その結果:
json/jsonbカラムに対して:
# これはエラーになっていた
serialize :col, coder: JSON # => ColumnNotSerializableError- しかし、等価なはずの:
# これはエラーにならず、JSONが二重エンコードされていた
serialize :col, coder: ActiveRecord::Coders::JSONという不整合が発生していました。
ネイティブ JSON カラムに対してさらに JSON シリアライザを噛ませると:
- ActiveRecord 型: JSON 型として一度エンコード
serializeレイヤー: さらに JSON としてエンコード
という「二重エンコード」が起き、DB上には "\"{\\\"foo\\\":\\\"bar\\\"}\"" のような「JSON文字列としてのJSON」が保存され、他クライアントからは文字列として見えてしまう、というデータ破壊が起きていました。
修正内容
type_incompatible_with_serialize? の互換性チェックを、「正規化済みの coder」を見るように変更しています。
- もともと:
coder: JSONのときだけ::JSON定数にマッチしてガードが働いていたcoder: ActiveRecord::Coders::JSONなど、他の表記ゆれは見逃されていた
- 修正後:
build_column_serializerが内部で行う「coderの正規化」後の値に対して判定を行う- そのため、
JSON/ActiveRecord::Coders::JSONなど、どのバリエーションでも「JSONとして扱われる coder」は同じ扱いになり、ネイティブjson/jsonbカラムには使えない、というチェックが統一的に効く
コード上の変更は activerecord/lib/active_record/attribute_methods/serialization.rb のわずかな比較ロジックの修正で、ロジックの位置はそのまま、参照する coder を「正規化済み」に変更した形です。
テスト
activerecord/test/cases/json_shared_test_cases.rb にテストが追加されています。ポイントは:
- ネイティブ
jsonorjsonbカラムに対して:serialize :col, coder: JSONserialize :col, coder: ActiveRecord::Coders::JSON
- などのケースが、いずれも
ColumnNotSerializableErrorとなることを検証
これにより、「どの JSON コーダー表記を使っても、ネイティブ JSON カラムとの組み合わせは防がれる」という期待される挙動が自動テストで保証されます。
CHANGELOG
activerecord/CHANGELOG.md に、バグフィックスとして追記されています。内容としては:
- ネイティブ JSON カラムと JSON コーダー付き serialize の組み合わせで、特定の coder 表記のときに二重エンコードが起こっていた問題を修正した、という旨。
- 影響範囲・注意点
影響範囲
- 対象バージョン以降の Rails では、以下のようなコードは 例外が発生 するようになります:
class User < ApplicationRecord
# users.profile が json/jsonb カラムの場合:
serialize :profile, coder: ActiveRecord::Coders::JSON # <= 以前は通っていたが、今後は ColumnNotSerializableError
end- 以前は「サイレントに二重エンコードされていた」コードが、本来意図されていた通りに「即座にエラーで気づける」ようになります。
既存アプリへの注意点
すでに壊れたJSONが入っている可能性
過去にjson/jsonbカラムにserialize+ JSON coder を組み合わせて使っていた場合、DB上に二重エンコードされたデータが残っている可能性があります。移行時には:- 該当するモデル・カラムを洗い出す
- 二重エンコードされたレコードを検出 (
JSON.parseを2回通さないと正常形にならない等) - 必要に応じて一括で修正マイグレーションを書く
コード修正の方向性
ネイティブ JSON カラムを使っている場合、基本的にserializeは不要であり、以下のいずれかに寄せるのが推奨です:serializeを削除して、カラムは普通に Hash / Array をそのまま読み書きする:ruby# NG (今後は例外): serialize :settings, coder: JSON # settings が json/jsonb カラムのとき # OK: # 何も指定せず、settings はそのまま Hash として扱うどうしてもカスタムシリアライゼーションが必要なら、ActiveRecord の Attribute API (
attribute :settings, :json, default: {}等) や Value Object を検討する。
ライブラリ・エンジン側の互換性
外部ライブラリやエンジンがserializeと JSON カラムを組み合わせて使用している場合、この修正によってColumnNotSerializableErrorが発生するようになるかもしれません。その場合:- ライブラリ側でネイティブ JSON カラムと
serializeの併用をやめる - あるいは、
textカラム +serializeなど、型構成を変える
といった対応が必要になります。
- ライブラリ側でネイティブ JSON カラムと
- 参考情報 (あれば)
- 本PR: https://github.com/rails/rails/pull/57611
- 関連PR (re-open元): #57609
- 類似の仕様・制約:
- Rails ガイド「Active Record のシリアライゼーション」
https://guides.rubyonrails.org/active_record_querying.html#serialization - 「ネイティブの json/jsonb カラムと
serializeの組み合わせは非推奨/サポート外」という設計方針に沿った修正といえるため、今後は Attribute API・ネイティブ型優先の方向に寄せると安全です。
- Rails ガイド「Active Record のシリアライゼーション」
#57601 Fix update_all / delete_all ignoring group/having (updates/deletes every row)
マージ日: 2026/6/6 | 作成者: @55728
- 概要 (1-2文で)
group/havingを含むがjoins/limit/offset/orderを含まない Relation に対してupdate_all/delete_allを呼ぶと、HAVINGが完全に無視されテーブル全行が更新・削除されてしまう不具合を修正した PR です。Arel の SQL 生成ロジックを修正し、純粋なgroup/havingケースでもサブクエリ経由で PK ベースに対象レコードを絞り込むようにしました。
- 変更内容の詳細
問題の挙動
以下のようなクエリを想定します:
# posts: (low, 0), (mid, 3), (high, 9)
Post.where(id: posts)
.group("posts.id")
.having("MAX(legacy_comments_count) >= 3")
.update_all(title: "updated")期待される動作は、「HAVING MAX(legacy_comments_count) >= 3 を満たすグループ (= レコード) だけを更新する」ことです。しかし、これまでの実装だと:
生成される SQL (全アダプタ共通) が概ね以下のようになり:
sqlUPDATE "posts" SET "title" = ? WHERE "id" IN (...)GROUP BY/HAVINGは完全にドロップされる結果として、
HAVINGを満たさない行も含め、WHERE id IN (...)にマッチする行がすべて更新・削除される
特に問題なのは:
- エラーにもならず
- deprecation warning も出ず
- サイレントに「対象行が増える」形でバグっていたこと
です。
原因の詳細 (Arel レベル)
update_all / delete_all は内部的には Arel の UpdateManager / DeleteManager を組み立てた上で、Arel::Visitors::ToSql#prepare_update_statement(および類似の delete 側)で最終的な SQL に変換されます。
この prepare_update_statement は、次のような条件で「サブクエリ (PK サブセレクト) で絞り込むかどうか」を決めます:
def prepare_update_statement(o)
if o.key && (has_limit_or_offset_or_orders?(o) || has_join_sources?(o))
# ... `WHERE pk IN (SELECT pk ... GROUP BY ... HAVING ...)` を組むパス
else
# ... それ以外 (素の UPDATE/DELETE を吐く)
end
endしかし、ここで考慮されているのは
has_limit_or_offset_or_orders?(o)has_join_sources?(o)
のみであり、has_group_by_and_having?(o) が考慮されていません。
has_group_by_and_having? 自体はすでに to_sql.rb に定義されているヘルパですが、トリガ条件に組み込まれていなかったため、**「group/having だけを持つ Relation」**は else 側に落ち、素の UPDATE / DELETE が生成される → GROUP BY / HAVING が無視される、というバグになっていました。
なお:
joins + group + havingのケースはhas_join_sources?に引っかかるため、すでに PK サブクエリ経由のパスを通っており、期待どおりの挙動になっていた- 「join なし group/having あり」のパスはテストが存在せず、どのアダプタでも未検証だった
という背景があります。
修正内容
prepare_update_statement(および delete 側相当)のトリガ条件に has_group_by_and_having?(o) を追加しました:
if o.key && (
has_limit_or_offset_or_orders?(o) ||
has_join_sources?(o) ||
has_group_by_and_having?(o)
)
# PK サブクエリを使うパスへ
endこの結果、join が無い純粋な group/having のケースでも、joins + group と同様に「PK サブセレクト形式」で SQL が生成されるようになります。
修正後の SQL のイメージ:
UPDATE "posts" SET "title" = ?
WHERE ("posts"."id") IN (
SELECT "posts"."id" FROM "posts" WHERE "id" IN (...)
GROUP BY "posts"."id"
HAVING (MAX(legacy_comments_count) >= 3)
)このサブクエリ生成ロジック (build_subselect) は元から groups / havings をコピーするよう実装されているため、HAVING 条件が正しく適用されるようになります。
テストの追加
以下の 2 つのテストが追加されています:
test_update_all_with_group_by_and_having_without_joinstest_delete_all_with_group_by_and_having_without_joins
どちらも:
joinsを使わずgroup/havingのみを用いてHAVINGを満たすレコードだけが更新・削除され、満たさないレコードはそのまま残る
ことを検証する内容です。
これらのテストは:
- sqlite3
- postgresql
- mysql2
の各アダプタで「修正前は失敗(全行更新/削除)→修正後は成功」することが確認されています。
ファイル変更としては:
activerecord/lib/arel/visitors/to_sql.rb- 条件式を
... || has_group_by_and_having?(o)に拡張
- 条件式を
activerecord/test/cases/relation/update_all_test.rb- 上記の pure group/having 用のテスト追加
activerecord/test/cases/relation/delete_all_test.rb- 同様に delete 用テスト追加
activerecord/CHANGELOG.md- バグ修正の履歴追記
- 影響範囲・注意点
挙動が変わるケース
次のようなクエリを書いていた場合、実行結果が変わります:
Post.group(:id)
.having("MAX(legacy_comments_count) >= 3")
.update_all(title: "updated")従来:
HAVINGが無視され、GROUP BYも消え- 実質的に
UPDATE posts SET title = 'updated'(+もしあれば where 条件)に近い挙動だった
今後:
HAVINGを満たすグループ(=レコード)にだけ更新がかかる
つまり、このバグに「依存していた」コードは壊れますが、依存している方が危険なため、正しい挙動に揃えられたと言えます。
アダプタ間の影響
- SQLite / PostgreSQL / MySQL の 全てで発生していた問題を、一箇所(Arel::ToSql の基底実装)を直すことで一括で解消しています
- MySQL は独自の
prepare_update_statementオーバライドを持ちますが、GROUP BY/HAVINGだけのケースは元々superにフォールバックしていたため- 今回の修正で
superの挙動が正しくなり、MySQL も同様に修正されます
GROUP BY の意味論上の注意
この PR は「HAVING がドロップされる」という致命的な問題を直すものですが、GROUP BY の意味論そのものは既存の joins + group パスに合わせただけで、DB 依存の部分はそのままです。
- 主キー、または主キーを一意に決定できる superkey で GROUP BY している場合
→ 「どのレコードが更新されるか」は明確(バグ修正でより直感的になっただけ) - 主キーを一意に決定しない列で GROUP BY している場合(例:
group(:status))- SQLite /
ONLY_FULL_GROUP_BYを無効にした MySQL など: 「各グループでどの行が選ばれるか」があいまい(従来からそう) - PostgreSQL や
ONLY_FULL_GROUP_BY有効な MySQL: そもそも SELECT 時点でエラーになり得る
- SQLite /
この PR はあくまで「HAVING を無視して全行を更新・削除してしまう」というバグを消すもので、これらの GROUP BY の仕様・DB 差異は変更しません。
マイグレーション時の注意
- 既に本番で
group/havingを伴うupdate_all/delete_allを使っている場合:- テストやログを見直し、「これまで意図せず全行更新・削除していたところがないか」「今後の挙動が本来の期待に合うか」を確認する価値があります
- 逆に、「一部だけ更新したかったのに、なぜか全部更新されている」という現象に悩まされていた場合:
- この修正で改善する可能性があります
- 参考情報 (あれば)
- この PR は #43465 で導入された「
joins + groupな Relation のupdate_all/delete_allに対する PK サブクエリ変換」を、純粋な group/having のケースにも拡張するものです。 has_group_by_and_having?自体は #43465 の一部 (4acb6660e2) で追加されており、本来は今回の条件に含まれるべきだったものの、当時はトリガ条件への組み込みが抜けていた「取りこぼし」を埋めた形です。- 変更行数はわずか(実質条件追加 + テスト)ですが、
- 影響は「データ破壊バグの予防」という意味で非常に大きいため、
group/havingを使う更新・削除ロジックのあるアプリではリリースノート・CHANGELOG を確認の上、アップグレード後の動作確認を推奨します。
#57605 Reset the memoized finder_needs_type_condition? flag on reset_column_information
マージ日: 2026/6/6 | 作成者: @55728
- 概要 (1-2文で)
reset_column_information/reload_schema_from_cache実行後も、STI 用のフラグfinder_needs_type_condition?の結果が古いまま残ってしまう不具合を修正する PR です。これにより、マイグレーションなどでtypeカラムを追加・削除した後に、STI 条件が効かなくなる/クエリがクラッシュする問題が解消されます。
- 変更内容の詳細
何が問題だったか
ActiveRecord の単一テーブル継承(STI)は、「このモデルに inheritance_column(デフォルトは type)が存在するか」を見て、クエリに WHERE type = 'Cat' のような条件を自動で付与するかどうかを finder_needs_type_condition? で判定しています。
この判定結果はインスタンス変数 @finder_needs_type_condition に メモ化 されており、通常はパフォーマンス上の最適化として妥当です。しかし、そのメモは以下のときにしかリセットされていませんでした。
- サブクラス定義 (
.inherited) 実行時
一方で、マイグレーション中などでよく使われる以下の API:
Model.reset_column_information- 内部的に呼ばれる
ModelSchema#reload_schema_from_cache
は、カラム情報まわりのキャッシュを多数リセットするにもかかわらず、@finder_needs_type_condition は消していなかったため、
- スキーマ変更前の状態に基づいた「STI 要 / 不要」判定が、その後もずっと使われ続ける
という状態になっていました。
その結果、2パターンのバグが起きます。
パターン1: もともと type カラムが無く、その後追加した場合
Cat.finder_needs_type_condition? # => false がメモ化される (type カラムがまだ無い)
connection.add_column :animals, :type, :string
Animal.reset_column_information
Cat.finder_needs_type_condition? # まだ false (本当は true になるべき)
Cat.create!(name: "felix").type # => nil # STI 用 type が保存されない
Cat.count # animals 全件を数える (WHERE type が付かない)影響:
- STI が 静かに無効化 される
- サブクラスのスコープが全行に広がる
createしてもtypeが書き込まれず、データ破損(誤った discriminator 値) につながる
パターン2: もともと type カラムがあり、その後削除した場合
Lion.count # => true がメモ化される (STI 有効)
connection.remove_column :zoos, :type
Zoo.reset_column_information
Lion.count # => "no such column: zoos.type" でクラッシュ影響:
- すでにメモ化された「STI 必要」フラグにより、もはや存在しないカラム
zoos.typeに対してWHEREが付与され、SQL エラーで落ちる
どちらも reset_column_information の利用例(マイグレーション中に change_table などと組み合わせるパターン)が公式にドキュメントされているため、実運用でも発生しうる不具合です。
修正内容
Inheritance#reload_schema_from_cache において、既に行っている他の STI 関連キャッシュのリセットに加え、@finder_needs_type_condition もリセットするようにしました。
疑似コード:
# activerecord/lib/active_record/inheritance.rb
def reload_schema_from_cache(*)
@finder_needs_type_condition = nil # ← これを追加
super
endこれにより、
- スキーマ変更前に一度
finder_needs_type_condition?が呼ばれてメモ化されていても、 - マイグレーション等で
typeカラムを追加・削除し、 reset_column_information/reload_schema_from_cacheを呼べば、
次回の finder_needs_type_condition? 呼び出し時に、最新のテーブル定義を元に再判定 されるようになります。
テストの追加
InheritanceFinderNeedsTypeConditionTest を追加し、以下のシナリオをテストしています。
- 一時テーブル上に STI 用のモデル群を定義
inheritance_columnが まだ存在しない状態でfinder_needs_type_condition?を呼んでメモ化- その後、
typeカラムを追加 reset_column_informationを実行- 期待される挙動を検証:
finder_needs_type_condition?が true になるcreateするとtypeにサブクラス名が保存される- サブクラスからの
where/countでWHERE type = ...が正しく付与される
テーブル定義を途中で変更する都合上、MySQL では DDL が暗黙コミットを伴うため、テストクラスでは use_transactional_tests = false が指定されています。
テストは以下のアダプタで fail → pass を確認済み:
- sqlite3
- postgresql
- mysql2
既存の関連テスト (inheritance_test, reload_models_test, base_test) もすべてグリーンです。
- 影響範囲・注意点
影響範囲
- 影響を受けるのは、STI を使っていて かつ以下のような操作を行うアプリケーションです。
- マイグレーション中に
typeカラムを追加・削除し、その場でreset_column_informationを呼ぶ - ランタイム中にテーブルの inheritance カラム周りを変更して
reset_column_informationで反映させる
- マイグレーション中に
この PR により、これらのパターンで:
- STI が無効のままになってしまう
- 存在しない
typeカラムへの参照で SQL エラーになる
といった不具合が解消されます。
既存コードへの影響(互換性)
- 挙動としては「本来そうあるべきだった STI の判定に修正される」ものなので、正常なコードへの悪影響は基本的にありません。
- ただし、もし「
typeカラムを追加したのにreset_column_information後も STI が効かない」という バグに依存した挙動 を前提にしているコードがある場合、それは修正の対象になります(そのような依存は避けるべきです)。
パフォーマンス
reload_schema_from_cache実行時にメモがクリアされることで、次回のfinder_needs_type_condition?呼び出し時にだけ 再計算が発生します。- これは既に他のスキーマ関連キャッシュでも行われているパターンであり、通常のアプリケーションでパフォーマンス問題になることはまずありません。
- 参考情報 (あれば)
- 本 PR が扱う問題は、以前から議論されている「
inheritance_column=変更後の古いメモによる不整合」の一種です。- 関連 issue: #31475(属性メソッドのメモ化リセット問題)
reset_column_information/reload_schema_from_cacheを用いた動的なスキーマ変更を行う場合は、- STI(
typeカラム) - 属性メソッド
- その他スキーマ依存のメモ まわりのキャッシュが更新されているかを意識しておくと安全です。本 PR により、少なくとも
finder_needs_type_condition?については自動で正しく更新されるようになりました。
- STI(
#57603 Fix Relation#cache_key crashing on a loaded collection with a NULL timestamp
マージ日: 2026/6/6 | 作成者: @55728
- 概要 (1–2文で)
ActiveRecord::Relation#cache_key/cache_versionが、「すでにロード済みの関連にNULLの timestamp を含むレコードがある場合」に限ってArgumentErrorで落ちる不具合を修正した PRです。未ロード時とロード済み時でNULLの扱いが食い違っていたのを揃え、どちらでも同じ cache key を生成するようになりました。
- 変更内容の詳細
問題の具体的な挙動
updated_at(などの timestamp 列)に NULL を含むレコードがあるとき、同じ Relation に対して以下のような差が出ていました:
# topics: 1件は updated_at = <time>、もう1件は updated_at = NULL
scope = Topic.where(id: [a.id, b.id])
scope.cache_key # => "topics/query-...-2-2026..." (OK: 未ロード)
scope.load.cache_key # => ArgumentError: comparison of Time with nil failedつまり、
- Relation が「未ロード」のとき →
cache_keyは正常に生成される - Relation が「ロード済み」のとき →
Timeとnilを比較しようとしてArgumentErrorが発生
という、「ロード状態」に依存したクラッシュが起きていました。
特に collection_cache_versioning = false(デフォルト)のときに再現します。
原因
Relation#compute_cache_version 内で、ロード済みかどうかで timestamp の取得方法が変わっており、その実装差が原因です。
if loaded?
size = records.size
if size > 0
timestamp = records.map { |record| record.read_attribute(timestamp_column) }.max
end
else
# ... SQL: MAX(timestamp_column) ...
end- 未ロードパス:
- DB に対して
SELECT MAX(updated_at) FROM ...のような SQL を発行 - SQL の
MAXはNULLを無視するため、updated_atが[Time, nil, ...]でも問題なくTimeが返る
- DB に対して
- ロード済みパス:
- Ruby 側で
records.map { ... }.maxを実行し、[Time, nil, ...].maxになってしまう Array#maxはTimeとnilを比較できないためArgumentError: comparison of Time with nil failedが発生
- Ruby 側で
このため、
- 未ロード:
NULLを無視 → 正常 - ロード済み:
NULLを含んだままmax→ 例外
という不一致が生じていました。
修正内容
ロード済みパスの timestamp 取得部分を、filter_map を使って NULL を除外するように変更しています。
# 変更前
timestamp = records.map { |record| record.read_attribute(timestamp_column) }.max
# 変更後
timestamp = records.filter_map { |record| record.read_attribute(timestamp_column) }.maxfilter_map はブロックの結果が「truthy のものだけを取り出す」のと同時に「結果の配列を返す」メソッドです。
read_attribute(timestamp_column)がnil→ 配列に含めないTime(truthy) → 配列に含める
結果として、records から「nil を含まない Time だけの配列」を作り、その .max を取るようになります。これは SQL の MAX(updated_at) と同じ挙動(NULL 無視)です。
all-NULL ケースの扱い
- すべてのレコードの
updated_atがNULLだった場合:- 未ロードパス:
MAX(updated_at)はNULL→ Ruby 側ではnil - ロード済みパス:
filter_mapにより空配列[]に対して.max→nil
- 未ロードパス:
となり、どちらのパスでも timestamp == nil で揃います。
元々 all-NULL の場合は nil が返っており、その挙動は維持されています。
テスト
activerecord/test/cases/collection_cache_key_test.rb に以下のテストが追加されています:
test "cache_key for a loaded relation with a NULL timestamp matches the unloaded key" do
# Topic は updated_at が nullable
# どれか1件の updated_at を update_all で NULL にする
# (create!(updated_at: nil) だとタイムスタンプコールバックに上書きされるため)
# そのうえで:
#
# - 未ロードの scope.cache_key
# - load 済みの scope.load.cache_key
#
# が一致することを検証
end- sqlite3 / postgresql / mysql2 で fail → pass を確認済み
collection_cache_key_test,cache_key_test,integration_testもグリーン
CHANGELOG にもこの修正が追記されています。
- 影響範囲・注意点
- 影響を受けるケース
cache @collectionなど、View で「一度その Relation をループ等でロードしたあとに cache する」パターン- ロード前:
@collection.cache_keyは成功 - ロード後: 同じコードが
ArgumentErrorで落ちる
- ロード前:
- モデルの timestamp (デフォルトでは
updated_at) がNULLになり得る場合- 手動で
update_all(updated_at: nil)しているケースなど
- 手動で
- この PR により:
- ロードされているかどうかに関わらず、
NULLを含む collection に対するcache_key/cache_version呼び出しが安定して動作する - 未ロード・ロード済みの両方で「非 NULL timestamp の最大値」を使った cache key が生成される
- ロードされているかどうかに関わらず、
- 互換性・副作用
NULLを含む collection で、従来は未ロード時とロード済み時で cache key が異なっていた可能性があります(ロード済み時は例外で落ちるため、実際に差異が利用されていた可能性は低い)- この PR によって「両方のパスで同じ key になる」ため、むしろ期待通りに安定化したと考えて良いです
filter_mapの導入以外にロジックの変更はなく、通常の timestamp がすべて埋まっているケースでは動作の違いはありません
- 参考情報 (あれば)
- 対象メソッド:
ActiveRecord::Relation#cache_key,#cache_version, 内部のcompute_cache_version - 典型的な利用箇所:
Action View の
cacheヘルパーで Relation をそのまま渡すコード:erb<% cache @topics do %> <% @topics.each do |topic| %> ... <% end %> <% end %>上記のように、
eachなどで一度ロードされた直後にcacheを呼ぶ場合、このバグの影響を受けていました。
この PR により、nullable な timestamp カラムを持つモデルを Relation ごとキャッシュしても、ロード状態によらず安全に cache_key / cache_version を利用できるようになっています。
#55061 [ci skip] Add docs for error context middleware
マージ日: 2026/6/5 | 作成者: @dersam
- 概要 (1-2文で)
Rails ガイドの「エラー報告」ドキュメントに、ActiveSupport::ErrorReporterの「error context middleware」(エラーコンテキスト用ミドルウェア)の使い方が追記された PR です。コードの振る舞い変更はなく、ドキュメントのみの追加です。
- 変更内容の詳細
※ 実際の diff は guides/source/error_reporting.md に 32 行追加のみで、機能追加ではなく解説追加です。以下は PR 説明と既存機能から推測した、追加された内容の要点です。
2-1. error context middleware とは何か
ActiveSupport::ErrorReporterには、エラー報告時に「コンテキスト情報」(ユーザーID、リクエスト情報、マルチテナントのテナントIDなど)を付与するための仕組みがあります。- その一つとして、「error context middleware」を定義し、Rack ミドルウェアのようにリクエスト処理の前後でコンテキストを自動設定できる機構があり、これについて Rails ガイドに説明が追加されています。
- これにより、アプリケーション全体で一貫してエラーコンテキストを付けるベストプラクティスがガイドに明文化されました。
2-2. 想定されるサンプルコードのイメージ
Rails ガイドによくあるスタイルを踏まえると、以下のような内容が追加されていると考えられます(あくまでイメージコード):
# config/initializers/error_reporter.rb
Rails.error.configure do |config|
# 例: error context middleware を登録
config.add_context_middleware do |context|
# 現在のスレッドローカルや Current オブジェクトから情報を引き出して
# コンテキストに追加する
if Current.user
context[:user_id] = Current.user.id
end
if Current.request_id
context[:request_id] = Current.request_id
end
end
endあるいは、Rack ミドルウェアやコントローラを通じてコンテキストを設定する例もガイドに書かれている可能性があります:
class ErrorContextMiddleware
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
Current.request_id = request.request_id
Current.user = find_current_user(request)
@app.call(env)
ensure
# スレッドローカルを必ずクリア
Current.reset
end
end
Rails.application.config.middleware.use ErrorContextMiddleware上記のような「Current オブジェクト + error context middleware」で、Rails.error.report(e) を呼んだ時に自動的に user_id や request_id などがコンテキストとしてレポートされる、という流れをガイドで説明していると考えられます。
2-3. ActiveSupport::ErrorReporter との関係
ActiveSupport::ErrorReporterはRails.errorから利用されるエラー報告インターフェースで、#report,#handle,#recordなどの API を提供します。- 今回のガイド追加は、このレポーターにコンテキストを注入するための「middleware 的なフック」の存在と、その使い方を示すものです。
- 既に存在していた API/機構に対する「公式な説明」を Rails ガイドに追加した位置づけです。
- 影響範囲・注意点
- 影響範囲はドキュメントのみであり、アプリケーションコードや Rails 本体の挙動には変更はありません。
- ただし、ガイドの更新により「推奨されるエラーコンテキストの設定方法」がより明確になったため、今後新規コードや既存コードのリファクタリング時に:
Rails.errorやActiveSupport::ErrorReporterを直接呼び出す箇所で、- 「毎回手でコンテキストハッシュを渡す」実装から、
- グローバルな error context middleware /
Currentパターンによる一元管理にリプレースしやすくなります。
- コンテキストに格納する情報は個人情報・機密情報になりがちなので、ガイドにも「不要な個人情報を含めない」「ログや外部エラー追跡サービスに送信されることを意識する」といった注意点が記載されている可能性があります。その点を踏まえた設計が必要です。
- 参考情報 (あれば)
- PR で参照されているプレビュー:
https://350ae6eb.rails-docs-preview.pages.dev/guides/error_reporting#setting-context-with-error-context-middleware - 関連クラス:
ActiveSupport::ErrorReporter(Rails.errorの実体)ActiveSupport::CurrentAttributes(Currentオブジェクト用)
- 既存ドキュメント(英語正式版に統合される見込み):
- Rails Guides: Error Reporting (error_reporting.md)
#57592 [RF-Docs] [ci-skip] Active Job Basics guide (#57101)
マージ日: 2026/6/5 | 作成者: @p8
- 概要 (1-2文で)
- Active Job Basics ガイド全体の構成を大幅に見直し、特に Solid Queue とキューイング周りの説明を、より概念的かつ実務的な内容に再編したドキュメント改善 PR です。
- 既存内容の重複や価値の薄いセクションを削除・統合し、キュー名/優先度/コールバック/失敗ジョブの扱いなどを一貫した流れで理解できるように整理しています。
- 変更内容の詳細
ガイド全体構成の再設計
- トップレベル・サブセクションを全面的に組み替え、「何を」「どの順で」学ぶかが分かりやすくなるよう整理。
- 小さな独立セクション(Job Testing, ActionMailer, Internationalization など Active Job ガイドとしては価値が薄いもの)を削除または他のセクションに統合。
- セクション名の見直し:
- “Create and Enqueue Jobs” → “Creating and Enqueuing Jobs” など、Rails Guides 全体の慣例に合わせてリネーム。
- “Queues” セクションは、実際に書いている内容に合わせ “Enqueuing Jobs”的な構造に再編(下位に「キュー名」「ダイナミックなエンキュー」「優先度」「バルクエンキュー」などをぶら下げる形に)。
Solid Queue セクションの全面書き直し
位置づけ:
- Solid Queue を「非同期なインプロセスのキューイング・バックエンド」として、用語・定義を明確化。
- 既存ガイドが GitHub の README の焼き直しになっていた部分を削除し、README ではなく「ガイドとして知るべき設計・特徴・ユースケース」にフォーカスした内容に変更。
内容面での強化ポイント:
- コンセプト説明:
- Solid Queue がどのように設計されているか、他バックエンドとどう違うか(例: DB を使う・インプロセスで動く・Rails 標準でサポートなど)を概念的に説明。
- 他のキューイングバックエンドとの比較観点(外部サービス型 vs アプリ内 DB 型、運用コスト、可観測性など)を整理。
- 設定例の強化:
database.ymlでの Solid Queue 用の接続定義例を、キュー用の行が目に付きやすいようにハイライトして説明。- development / production の両方について、どのように queue DB を分けるか(または共用するか)を示す。
- キュー定義と優先度:
- 「定義されたキューの順序がジョブの priority より優先される」点を、具体的な例で説明し直し。
- 例:
queues: [ "production", "background" ]を定義しているときに、priority とキューの組み合わせで実際の処理順がどう決まるか、を表に近い形やサンプルで解説。
- 例:
- 「定義されたキューの順序がジョブの priority より優先される」点を、具体的な例で説明し直し。
- 警告表現の統一:
- 以前は
⚠️絵文字で警告を表現していた箇所を、Guides 標準の WARNING ブロック(注記ブロック)を使うよう変更。
- 以前は
- コンセプト説明:
ジョブのエンキューとキュー名・優先度
Enqueuing Jobs / Queues 関連の再構成
セクション名変更と再構造化:
- 「Queues」という抽象的タイトルをやめ、実際に扱う内容(ジョブのキューイング)に合わせて再構成。
- 下位セクションを “Naming Queues”, “Dynamic Enqueuing”, “Priority”, “Bulk Enqueuing” のように整理。
キュー名の付け方(Naming Queues):
- 大量のキューを運用する場合の命名規則について、実務的なガイダンスを追加。
- 特に「レイテンシベースのキュー名」(例:
critical,high,default,low)のようなスキームを「よく使われる選択肢」として紹介。- Gusto 社のブログ “Scaling Sidekiq at Gusto” を参照しつつ、「推奨」というより「一つのメジャーなパターン」として紹介する、というトーンに調整。
ダイナミックなエンキュー(Dynamic Enqueuing):
- 実行時にキュー名を切り替えたり、状況に応じて queue / priority を変えるパターンの説明をセクションとして分離。
- 例:ruby
MyJob.set(queue: :low).perform_later(record) MyJob.set(wait: 5.minutes, queue: :high).perform_later(record)
優先度(Priority):
- これを独立セクションではなく、上記「Enqueuing Jobs」配下のサブセクションに移動し、キュー名と合わせて「ジョブの実行順序に効くパラメータ」という文脈で説明。
- 例:rubyといった形で、
class CriticalJob < ApplicationJob queue_as :critical priority 10 endqueue_asとpriorityの関係性がわかるような説明になっている。
バルクエンキュー(Bulk Enqueuing):
- “Enqueuing Jobs” セクションの一部(サブセクション)として取り込み。
- 上部に別見出しを立ててリンクだけする形ではなく、「2.3 Enqueue Jobs in Bulk」内にトップの説明をインライン展開するように修正。
- 無意味な文言削除:
- “
perform_all_lateris a top-level API on Active Job.” など、情報量が薄い説明を削除し、具体的な使い方に紙幅を割く。
- “
ジョブ定義・引数・コールバック
Supported Types for Arguments:
- 引数に渡せる型の一覧・ルールを「ジョブ定義」関連のセクションに移動。
Enqueuing Jobsというより「Job の API 面での仕様」なので、Defining a Job的なセクションのサブセクションとして再配置。
コールバック(Callbacks):
- 以前は例だけで説明が薄かったのを、全ての利用可能なコールバック(
before_enqueue,around_perform, など)について、短いコードスニペット付きで明示。- 例:ruby
class MyJob < ApplicationJob before_enqueue :log_enqueue after_perform :notify private def log_enqueue Rails.logger.info("Enqueuing #{job_id}") end def notify # ... end end
- 例:
- コードブロック内で、実際にコールバックに関わる行だけを行ハイライトして、どこがポイントか読み取りやすくした。
- 以前は例だけで説明が薄かったのを、全ての利用可能なコールバック(
コールバックの中断(halting callbacks):
- 別 PR(#53541)で追加される予定だった「コールバックの halt」機能を、このガイドに明示的に説明するセクションを追加。
- 例として、
throw :abortあるいは専用 API による処理中断と、そのときキューイング/実行がどう扱われるかを説明しているはずです。
例外処理・デバッグ・失敗ジョブ
“Exceptions” セクションのリネーム:
- タイトルを “Handling Failed Jobs” 的な名前に変更し、「例外」ではなく「失敗したジョブをどう扱うか」という読者視点のテーマに合わせた。
- 失敗時のリトライ戦略やハンドラ、ロギング・通知など、実際の運用に直結する項目をこのセクションに寄せる形に調整。
Debugging セクション:
- 独立した「デバッグ」章ではなく、「Handling Failed Jobs」周辺にマージし、失敗時の調査・ログ・スタックトレースの確認方法をまとめて扱う流れに。
- verbose enqueuing logging:
- development ではデフォルトで有効なため、単独のセクションとして扱うほどの重みはないと判断し、扱いを縮小/統合。
不要・重複セクションの削除
ActionMailer:
- Active Job 経由でメール送信する説明は、Action Mailer ガイドにすでに存在しており、Active Job ガイドで重複して持つのは冗長なため削除。
- 同じ理屈で、ActionMailbox (
incinerate_later)、ActiveStorage (purge_later) などもここでは扱わず、各フレームワークガイドに任せる構成に。
Internationalization:
- 実質的にメール送信や文言の I18n 寄りの内容であり、Active Job のコアテーマではないため削除。
Job Testing:
- ここで軽く触れる程度の情報は価値が薄く、かつ他フレームワーク(Active Record, Action Controllerなど)も「テスト」専用サマリを持っていないことから、削除。
代替キューイングバックエンド
- Alternate Queuing Backends セクション:
- Solid Queue セクションとの位置関係を見直し。
- Rails Foundation ドキュメントの方針として Solid Queue を第一クラス市民として扱いたい一方で、他バックエンドの情報も隣接させて比較しやすいように、両者のセクションを(少なくとも論理的には)近接させるよう再配置。
- 影響範囲・注意点
コード挙動への影響:
- この PR は guides(ドキュメント)のみ変更であり、Rails のランタイムコードには一切触れていません。そのためアプリケーション動作への直接的な影響はありません。
情報ソースとしての影響:
- Active Job の公式ガイドとして参照する際、以下が変わります:
- Solid Queue については README ではなく、「ガイドとしての整理された概念説明」を読むことになる。
- ActionMailer, I18n, Job Testing など周辺事項は、このガイドからはリンク/言及が減り、各専用ガイドに誘導される構成になる。
- 古いバージョンのガイドを前提にブログ・社内 Wiki 等で参照している場合、セクション構成や見出し名が変わっているため、リンク切れや説明の位置変化に注意が必要です。
- Active Job の公式ガイドとして参照する際、以下が変わります:
ドキュメント執筆・翻訳への影響:
- セクション構成が大きく変わったため、ローカル言語版(日本語訳など)を持っている組織は、翻訳の差分反映コストが高くなります。
- 特に Solid Queue, Enqueuing Jobs, Callbacks, Handling Failed Jobs 周りを、原文に合わせて再翻訳・再構成する必要があります。
- 参考情報 (あれば)
- この PR 自体:
- Solid Queue README(設計・詳細仕様):
- キュー名のレイテンシベース命名の参考記事:
- Scaling Sidekiq at Gusto — Latency-based queue naming:
https://engineering.gusto.com/scaling-sidekiq-at-gusto-3f9e3279e63
- Scaling Sidekiq at Gusto — Latency-based queue naming:
- コールバックの halting に関する関連 PR:
#57101 [RF-Docs] [ci-skip] Active Job Basics guide
マージ日: 2026/6/5 | 作成者: @bhumi1102
- 概要 (1-2文で)
Active Job BasicsガイドをRails Foundationのドキュメント方針に沿って全面的に再構成し、特にSolid Queueの説明を「設計・特徴・使い方」の観点から整理し直したPRです。細かい・重複したセクションを削除・統合し、キュー名/優先度/コールバック/失敗時の扱いなど、実務で重要なテーマごとに読みやすく再編成しています。
- 変更内容の詳細
全体構成の再編・統合
- ガイド全体のトップレベル・サブセクションを見直し、細切れだった章を統合して流れを整理。
- 「Queues」「Priority」「Bulk Enqueuing」「Supported Types」「Debugging」など、関連性が高いのにバラけていた内容を、「ジョブ定義」「ジョブのエンキュー」「失敗ジョブの扱い」といった大きめのテーマに再配置。
- 章タイトルをガイド全体の命名規則に合わせてリネーム
- 例: 「Create and Enqueue Jobs」→「Creating and Enqueuing Jobs」など。
Action Mailer / I18n / Job Testing の整理
- Active Job固有でない、もしくは他ガイドと重複していた以下のセクションを削除:
- ActionMailer連携(Action Mailerガイドでカバー済み)
- Internationalization(主にMailer文脈での話であり、Active Job固有ではない)
- Job Testing(他フレームワークに同等セクションがなく、内容的にも薄い)
- 代わりに、Active Jobのコア概念・使い方に紙幅を集中させる構成になっています。
Solid Queue セクションの全面書き換え
このPRの中心は、Solid Queueの説明を単なるREADMEの焼き直しから、「Rails標準のキューバックエンドとしての位置づけ・特徴・設計思想」を伝えるガイドに変えた点です。
コンセプト・設計の説明を重視
- Solid Queue を以下のような観点で説明する構成に変更:
- Railsに組み込みやすい「非同期・インプロセス・キューバックエンド」であること
- “queuing system” ではなく “queuing backend” という用語に揃え、Active Jobのバックエンドとしての役割を明確化
- 他のバックエンド(Sidekiq, Resqueなど)との違い
- DBベース、Railsとの親和性、インフラ依存の少なさなどの特徴を、概念レベルで説明
- 実運用で意識すべき点(キュー定義、優先度、ワーカー構成など)
- Railsに組み込みやすい「非同期・インプロセス・キューバックエンド」であること
- READMEの内容をそのままコピーするのではなく、
- 「どういうときに・どういう考え方でSolid Queueを選ぶか」
- 「設計上何がユニークか」 を中心に説明し、詳細なオプションなどはREADMEに委ねる構成になっています。
設定例の明確化
config/database.ymlの例で、Solid Queue用の接続/設定行がより目立つように記述を整理- PRコメントに基づき、queueに関する行を強調。
- 開発環境・本番環境両方の例を示し、「RailsアプリのDBと同一/別DBを使うケース」などの具体的イメージがしやすいよう編集。
キュー定義と優先度の関係を具体例で説明
- 「定義されたキューの順序がジョブのpriorityより優先される」という振る舞いを、より明確な例付きで説明。
- 例:
productionキューとbackgroundキューを定義したうえで、それぞれに priority が違うジョブが入っていても、キューの処理順が優先されることを図やサンプルコードで説明、という形に変更。 - これにより、「priorityを上げてもキュー順序に勝てない」ケースの混乱を防ぐ意図。
- 例:
警告スタイルの統一
- テキスト中で使われていた ⚠️ を、Railsガイド標準の WARNING ブロックに置き換え。
- セクション全体をWARNINGにするのではなく、本当に注意喚起が必要なポイントだけをブロックに分離し、可読性と意味合いを整理。
キューとエンキュー周りの整理
「Queues」→「Enqueuing Jobs」系への再構成
- 従来「Queues」というタイトルの下に混ざっていた内容を、実態に合わせて再分類:
- 「キューの命名(Naming Queues)」
- 「動的なエンキュー(Dynamic Enqueuing)」
- 「優先度(Priority)」※もとの独立セクションをここに統合
- 目的:読者視点では「キューというデータ構造の内部」よりも、「どうエンキューするか」「どう振る舞いを制御するか」が知りたいため、その観点で章を立て直し。
キュー命名のベストプラクティスへの言及
- Latency-based naming(レイテンシベースのキュー命名)を「よく使われるパターン」として紹介:
- 例:
mailers_fast,mailers_slow,critical,default,lowのように、処理の緊急度/レイテンシに応じたキューを分ける
- 例:
- Jean Boussierのコメントに従い、「唯一の正解としての推奨」ではなく「広く使われているオプション」として中立的に記述。
コールバック・ライフサイクルの改善
コールバック一覧とコード例の充実
- Active Jobの各種コールバック(
before_enqueue,around_perform,after_performなど)について、他のガイド同様に- 各コールバックの目的/タイミングの簡潔な説明
- 該当部分だけを行ハイライトで示したサンプルコード を追加し、理解しやすく整理。
- コールバックの種類だけ列挙されていた従来版と違い、「いつ・何をするのに向いているか」まで含めて分かりやすくなっています。
コールバックの「中断(halting)」の追加
- 独立PR (#53541) で追加予定だった、コールバックを途中で中断する挙動に関する説明セクションを統合。
- 例:
throw :abortによるbefore_enqueueの中断など - 中断した際にジョブがどう扱われるか(エンキューされない/実行されないなど)を明示。
- 例:
- コールバックまわりで実装側がハマりがちなケース(条件付き実行、バリデーション的な使い方)への対応も、ガイドだけで把握しやすくなっています。
バルクエンキュー (Bulk Enqueuing)
- 「Enqueuing Jobs」系セクションのサブセクションとして再配置し、ジョブ投入手段を一箇所にまとめる構成に変更。
perform_all_laterの説明から、意味の薄い文(「top-level API である」等)を削除し、実際の使い方にフォーカス。- 例:
- 複数のレコードに対して同種のジョブを一括エンキューするケース
- 単発の
perform_laterループとの違い(トランザクション境界・オーバーヘッド)などをコード例で示す形になっていると考えられます(差分行数的に詳細が増えている)。
- 例:
サポートされる引数型 (Supported Types for Arguments)
- もともと「Enqueuing Jobs」直下にあった/あるいはガイド後半に散在していた内容を、「ジョブ定義」の文脈に近いセクションへ移動。
- 目的:ジョブを定義する際に「どの型を引数に渡せるか」を意識して設計できるようにするため。
- Active Jobがシリアライズ可能な型(基本型、ActiveRecordモデル、GlobalID対応オブジェクト等)について解説するセクションとして整理されているはずです。
失敗ジョブとデバッグ
「Exceptions」→「Handling Failed Jobs」へリネーム
- セクションタイトルを「Exceptions」から「Handling Failed Jobs」に変更し、内容に即した名称に。
- リトライ、デッドレター(もし記載があれば)、ログの確認など、失敗時の扱いを中心に説明。
デバッグ内容の統合
- 独立していた「Debugging」セクションを「Handling Failed Jobs」配下に統合。
- Railsの開発環境では verbose なエンキューログがデフォルト有効であるため、これを「特別な設定」として扱う記述をやめ、失敗ジョブの原因究明の流れの中で自然に説明する構成に変更。
代替キューバックエンド (Alternate Queuing Backends)
- Solid Queueセクションと隣接するように再配置。
- Rails標準(Solid Queue)→他バックエンドという流れで読み進められるように。
- あるいは、Solid Queueをやや強調しつつも、最後に「他にもこういう選択肢があります」と紹介する位置づけ。
- 目的は、「Solid Queueがデフォルトである」ことを明確にしつつ、「Sidekiqなど他のエコシステムと連携する余地もある」ことを示すバランスをとること。
- 影響範囲・注意点
- コード(Active Job本体)には変更はなく、あくまでガイド(
guides/source/active_job_basics.md)のみの変更です。 - ただし、ガイドを準拠情報としているチーム・社内Wikiなどがある場合:
- セクション名・アンカー(
#queues→#enqueuing-jobsのような)変更により、リンク切れが発生する可能性があります。 - 「ActionMailer」「Internationalization」「Job Testing」などのサブセクションは削除されているため、それらへの直接リンクは確認が必要です。
- セクション名・アンカー(
- Solid Queueの説明が「設計や特徴中心」に変わったため、
- 以前READMEの内容を引用したような運用手順をガイド経由で参照していた場合、今後はSolid QueueのREADMEとの併用が前提になる箇所があります。
- コールバックの halting(中断)に関する記述が明示されたことで、これまで暗黙的に(or バグ扱いで)利用していたコードの意味がドキュメント的に「仕様」として整理されます。
- 新規実装時には、halting の有無を意識した設計がしやすくなる一方で、既存コードの振る舞い確認もしておくと安心です。
- 参考情報 (あれば)
- 対象ガイドファイル
guides/source/active_job_basics.md(+968 / -759 行の大幅変更)
- 関連PR
- コールバックの halting を扱うPR: https://github.com/rails/rails/pull/53541
- キュー命名に関する参考記事(ガイド内でも参照されているパターン)
- Scaling Sidekiq at Gusto: Latency-based queue naming
https://engineering.gusto.com/scaling-sidekiq-at-gusto-3f9e3279e63
- Scaling Sidekiq at Gusto: Latency-based queue naming
- Solid Queue README(詳細オプションやコマンドはガイドではなくこちらを参照する想定)
- https://github.com/rails/solid_queue (URLは一般的なパス。実際のリポジトリ名変更などには注意)
#57575 Clear the type column when removing a polymorphic has_one
マージ日: 2026/6/5 | 作成者: @55728
- 概要 (1-2文で)
polymorphic なhas_one関連を代入で外す(owner.child = nil/owner.child = other)際に、外部キーだけでなく<name>_typeも確実にNULLにするように修正した PR です。これにより、dependent: :nullify経由の「nullify」と挙動が統一され、一方向の polymorphichas_oneで発生していた「type カラムが古いまま残る」不整合が解消されます。
- 変更内容の詳細
これまでの挙動
対象は「polymorphic な has_one 関連」を、代入で「外す」ケースです:
owner.thing = other # 付け替え
owner.thing = nil # 削除こうした操作を行うと、子側のレコードにある「外部キー」カラム(thing_id など)は NULL になりますが、polymorphic の「type」カラム(thing_type)は以前の値が残っていました。
内部的には HasOneAssociation#nullify_owner_attributes が呼ばれますが、ここでは外部キーしか消していませんでした:
# activerecord/lib/active_record/associations/has_one_association.rb
def nullify_owner_attributes(record)
Array(reflection.foreign_key).each do |foreign_key_column|
record[foreign_key_column] = nil unless foreign_key_column.in?(Array(record.class.primary_key))
end
end一方、dependent: :nullify を指定して関連を切る場合は、ForeignAssociation#nullified_owner_attributes が使われます。こちらは type カラムも消します:
def nullified_owner_attributes
Hash.new.tap do |attrs|
Array(reflection.foreign_key).each { |foreign_key| attrs[foreign_key] = nil }
attrs[reflection.type] = nil if reflection.type.present?
end
endそのため、次の2パターンで挙動が食い違っていました:
owner.thing = nil- 外部キー:
NULL - type: 古い値のまま
- 外部キー:
has_one :thing, dependent: :nullify経由の nullify- 外部キー:
NULL - type:
NULL
- 外部キー:
特に、子モデル側が inverse の belongs_to を持たない一方向の polymorphic has_one で問題が顕在化します。逆方向の belongs_to があれば、そのロジックの中で type もリセットされるため、これまで多くのケースでは問題が「見えにくい」状態でした。
再現例
class Human < ActiveRecord::Base
has_one :face, as: :describable # Face(describable_id, describable_type)
end
class Face < ActiveRecord::Base
# NOTE: belongs_to :describable は定義しない(片方向)
end
h = Human.create!
h.face = Face.create! # describable_id + describable_type = "Human"
h.face = nil
face.reload
face.describable_id # => nil
face.describable_type # => "Human" # ← ここが古い値のまま修正内容
HasOneAssociation#nullify_owner_attributes に、polymorphic の type カラムも NULL にする処理を追加しました:
def nullify_owner_attributes(record)
Array(reflection.foreign_key).each do |foreign_key_column|
record[foreign_key_column] = nil unless foreign_key_column.in?(Array(record.class.primary_key))
end
# 追加された行
record[reflection.type] = nil if reflection.type.present?
end- polymorphic でない
has_oneの場合はreflection.typeがnilなので、この行は no-op になります。 - これにより、代入形式の nullify と
dependent: :nullifyの両方で、「id も type も消える」という一貫した挙動になります。
テスト
activerecord/test/cases/associations/has_one_associations_test.rb に以下のテストが追加されています:
test_replacing_a_polymorphic_has_one_nullifies_the_type_column
ポイント:
- 一方向 polymorphic
has_one(子側に inversebelongs_toがない)を使ったテストケース owner.child = nil実行後に、子の<name>_idと<name>_typeの両方がnilであることを検証- 変更前の
mainでは type が"...Owner"のままになりテストが落ちる - この修正によりテストがグリーンになる
- sqlite3 / postgresql / mysql2 すべてでテストスイートがパス
CHANGELOG.md にも、この挙動変更が追加されています。
- 影響範囲・注意点
影響を受けるケース
- polymorphic な
has_oneを定義しているモデル - その関連を「代入で外す」コードがある場合:
owner.child = nilowner.child = another_child
- 特に、子側が inverse
belongs_toを定義していない 一方向 polymorphichas_oneでこれまで挙動が変だったところが正しくなります。
- polymorphic な
これまでと挙動が変わる点
- 以前は「外部キーは
NULLになっているが type だけ古い値が残る」ため、データ的には不整合な状態でしたが、アプリ側の独自ロジックや直接 SQL を書く箇所で、この「stale な type カラム」に依存していた場合は挙動が変わる可能性があります。- 例:
WHERE describable_type = 'Human' AND describable_id IS NULLのようなクエリを「外れた Face だけを拾う」意図で書いていた場合など
- 例:
- Rails 的には今回の挙動が一貫性があり正しいと考えられるため、通常の関連操作に依存している限りは「バグ修正」として問題なく受け入れられる変更です。
- 以前は「外部キーは
非 polymorphic の
has_onereflection.typeがnilのため、挙動は一切変わりません。
dependent: :nullifyを使っている場合- もともと id + type ともに
NULLにしていたので挙動は据え置きです。 - 今回の変更で「代入で外した場合」との不整合が解消され、どのルートでも同じ状態になります。
- もともと id + type ともに
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57575
- 修正対象のメソッド:
activerecord/lib/active_record/associations/has_one_association.rb#nullify_owner_attributes
- 類似ロジック:
activerecord/lib/active_record/associations/foreign_association.rb#nullified_owner_attributes
- 関連テスト:
activerecord/test/cases/associations/has_one_associations_test.rb
- 挙動確認をしたい場合は、自分のアプリで問題になりそうな polymorphic
has_oneを定義して、owner.child = nil→ 子レコードをreload→<name>_id/<name>_typeの値を確認すると差分を把握しやすいです。
#57566 Fix MessagePack serialization of records with a time column
マージ日: 2026/6/5 | 作成者: @55728
- 概要 (1-2文で)
time型カラムを持つ ActiveRecord オブジェクトをActiveRecord::MessagePackでシリアライズするとNoMethodErrorが発生していた問題を修正した PR です。ActiveRecord::Type::Time::Valueを MessagePack の対象型として正式に登録することで、Rails.cache.write等でtimeカラムを含むレコードを安全にキャッシュできるようになりました。
- 変更内容の詳細
問題の内容
ActiveRecord::MessagePack を経由してレコードをシリアライズするとき、time 型カラムが存在すると以下のようなエラーが発生していました。
NoMethodError: undefined method 'to_msgpack' for an instance of ActiveRecord::Type::Time::Value原因は以下の通りです。
attributes_for_databaseはtimeカラムの値をActiveRecord::Type::Time::Valueオブジェクトにラップして扱う- これは
DelegateClass(::Time)を継承したクラス(Timeのデリゲート)
- これは
- 一方、MessagePack のファクトリは 厳密なクラス一致 で型を登録しており、
::Timeだけを登録していた - そのため
ActiveRecord::Type::Time::Valueは::Timeとみなされず、MessagePack 側で対応する拡張型が見つからずto_msgpack未定義エラーに落ちていた
datetime / date / decimal については、Time / Date / BigDecimal の素の値でシリアライズされていたため、この問題の影響を受けていません。
このバグは Rails 7.1 で MessagePack サポートが入った時点から存在していましたが、既存の message_pack_test.rb で使われているモデル(Post/Comment/Author/Binary)には time カラムが無かったため、これまでテストに引っかかっていませんでした。
修正内容
activerecord/lib/active_record/message_pack.rb にて、MessagePack ファクトリへ ActiveRecord::Type::Time::Value を拡張型として登録する処理が追加されました。
ActiveRecord::Type::Time::Valueを type ID 118 として登録- 既存の
Binary::DataやBase登録と同様に、再帰的 (recursive) な登録方法で行っている- これは、MessagePack でカスタムクラスを扱う際に、ネストされたオブジェクトを含めて適切にエンコード / デコードできるようにするためのパターンです
概念的には、次のような登録をしているイメージです(擬似コード):
# 118 番の拡張型として ActiveRecord::Type::Time::Value を登録するイメージ
factory.register_type(118, ActiveRecord::Type::Time::Value) do |time_value|
# 内部的には実際の Time としてエンコードし、復元時に適切に再構築する
end(実際のコードは既存の拡張型登録のスタイルに合わせて書かれており、Binary の登録と同様のパターンで実装されています。)
テスト追加・更新
activerecord/test/cases/message_pack_test.rb に以下のテストが追加・更新されました。
test_roundtrips_time_attributeTopicモデルを使い、そのうちbonus_timeというtimeカラムを持つ属性が MessagePack を経由してラウンドトリップ(serialize → deserialize)できることを検証- 修正前はここで
NoMethodErrorが再現し、修正後は正常にパスするテスト
test_enshrines_type_IDsの更新- 既存の拡張型 ID を固定化しておくテストに、type 118 を追加
- これにより、拡張型 ID の将来的な変更が、互換性に影響することを検知できるようになっている
また、activerecord/CHANGELOG.md にも、この修正が反映されています。
- 影響範囲・注意点
影響範囲
ActiveRecord::MessagePackを使う経路(特にRails.cache)で、timeカラムを持つレコードを扱うケース全般- 例: Solid Cache や他のキャッシュストアで ActiveRecord オブジェクトをそのままキャッシュしている場合
- これまで:
timeカラムがあるレコードをRails.cache.write(record)などすると、NoMethodErrorが発生
- この PR 適用後:
- 同じコードがエラーなく動作し、
time属性も含めて正しくシリアライズ / デシリアライズ可能
- 同じコードがエラーなく動作し、
互換性・注意点
- 新たに使用した拡張型 ID は 118 で、既存の type ID と衝突しないように選定されています
test_enshrines_type_IDsにより、ID 割り当てが将来変更された場合の破壊的変更を検出できるようになっています
datetime/date/decimalなど他の型の挙動は変更されていませんActiveRecord::Type::Time::Valueのシリアライズ形式が今後変わると互換性に影響する可能性があるため、拡張型 ID 118 の扱いはバージョン間互換の観点で慎重に扱う必要があります
- 参考情報 (あれば)
- 対象バージョン: Rails 7.1 以降の
ActiveRecord::MessagePack機能に潜んでいたバグの修正 - 関連箇所:
ActiveRecord::Type::Time::Valueは 2016年に導入されたDelegateClass(::Time)のサブクラス- MessagePack サポートは Rails 7.1 で導入
- 想定利用箇所:
Rails.cache(ActiveSupport::MessagePack::CacheSerializer経由)- Solid Cache を含む Rails 標準・外部のキャッシュストアでの ActiveRecord モデルキャッシュ
#57590 Fix order-dependent test failures with sqlite3_mem
マージ日: 2026/6/5 | 作成者: @ruyrocha
- 概要 (1-2文で)
ARCONN=sqlite3_memでのテスト実行時に、テスト実行順によって約3000件の失敗を引き起こしていた原因となる2つのテストを修正し、SQLite の in-memory DB 環境でもテストスイート全体が安定して通るようにした PR です。グローバルな SchemaCache の破壊と、:memory:DB 自体の破棄を招いていたテストの挙動を修正・制限しています。
- 変更内容の詳細
2-1. DirtyTest#test_field_named_field の修正 (SchemaCache 破損の解消)
問題点
DirtyTest#test_field_named_field というテストが、以下のような流れで動いていました:
create_table/drop_tableを使って一時的なテーブルを作成・削除ensureブロックでActiveRecord::Base.clear_cache!を呼び出し
ActiveRecord::Base.clear_cache! はグローバルな SchemaCache を空にした新しいインスタンスで置き換えるため、
テストファイル単体では通るものの、テストスイート全体で実行すると、その後に動く多数のテストが「テーブルが見つからない」エラーで落ちていました。
SQLite in-memory DB (:memory:) の場合は特に、接続ごとに DB がメモリ内にだけ存在し、Schema 情報も都度ロードし直す必要があるため、SchemaCache を無造作に吹き飛ばすと影響が大きくなります。
対応内容
ActiveRecord::Base.clear_cache!を使った一時テーブル方式をやめ、既存のpiratesテーブルを使うアプローチに変更- テスト対象用に匿名クラス +
attributeAPI を使う形に書き換え
同じファイル内に既にあるテスト
test "attribute_will_change! doesn't try to save non-persistable attributes"などと同じパターンに合わせ、明示的なテーブル作成/削除やグローバルキャッシュ操作に依存しないようにしています。
これに伴い、トップレベルで定義されていた Testings クラスも不要になったため削除されています。
※具体的な差分はおおむね以下のようなイメージです(擬似コード):
# 以前(イメージ)
def test_field_named_field
begin
connection.create_table(:testings) { |t| t.string :field }
klass = Class.new(ActiveRecord::Base) { self.table_name = "testings" }
# ... テスト本体 ...
ensure
connection.drop_table(:testings) rescue nil
ActiveRecord::Base.clear_cache!
end
end
# 以後(イメージ)
def test_field_named_field
klass = Class.new(ActiveRecord::Base) do
self.table_name = "pirates"
attribute :field, :string
end
# ... テスト本体(field という名前の attribute を扱う検証) ...
endこれにより、テスト終了時に SchemaCache をクリアする必要がなくなり、他テストへの副作用をなくしています。
2-2. AdapterConnectionTest の sql_notifications 系テストの修正
問題点
activerecord/test/cases/adapter_test.rb の AdapterConnectionTest には、SQLite in-memory DB 用のガードが既に存在していました:
unless in_memory_db?
# ... 多くのテスト ...
endしかし、そのクラス内にある
test_suppresses_notifications_when_sql_notifications=falsetest_sql_notifications_are_enabled_by_default
の2つのテストだけが unless in_memory_db? の外に置かれていました。
これらのテストは run_without_connection を利用しており、その内部では:
remove_connectionを呼ぶ- その後
establish_connectionで再接続する
という挙動をします。
SQLite で database: ':memory:' を使う場合、remove_connection によってメモリ上の DB 自体が「完全に消える」ため、その後の establish_connection では「空の新しい in-memory DB」が作成され、スキーマ情報がすべて失われます。
このため、これら2テスト実行後に続く多数のテストが「テーブルが存在しない」などのエラーで失敗していました。
対応内容
- 上記2つのテストを
unless in_memory_db?ブロックの中に移動し、SQLite in-memory 環境では実行されないように変更
これにより、SQLite :memory: 環境では DB 接続の remove/establish によってスキーマが消えるパターンを避け、テスト順依存の失敗を防いでいます。
なお、unconnected_test.rb では既に
- in-memory DB で
remove_connection相当の操作を行う場合 - teardown で
load_schema if in_memory_db?を呼んでスキーマを復元
という正しいパターンが実装済みであり、本 PR はそれに合わせて「そもそも in-memory ではその危険なパターンのテストを実行しない」という整理をしています。
- 影響範囲・注意点
- 影響範囲はテストコードに限定され、アプリケーション本体の挙動やパブリック API には変更はありません。
ARCONN=sqlite3_memでのテスト実行時に、大量の順序依存テスト失敗が解消されるため、Rails 本体やプラグイン開発者が CI 等で SQLite in-memory を使う場合の安定性が向上します。- in-memory DB で
remove_connection/establish_connectionを行うと DB 自体が消える、という SQLite 特有の性質が改めて明確になった形なので、アプリや他テストコードでも同様のパターンを使う場合は:- そもそも in-memory では実行しないガードを付ける
- もしくはテスト後に
load_schemaなどでスキーマを復元する といった対策が必要になります。
- SchemaCache をテスト内でいじる場合も、
ActiveRecord::Base.clear_cache!のようなグローバルな操作は極力避け、既存テーブル+属性 API の利用など、スキーマ自体に影響しない設計が望まれます。
- 参考情報 (あれば)
- 該当 Issue: Fixes #57589
- 修正対象ファイル
activerecord/test/cases/adapter_test.rbactiverecord/test/cases/dirty_test.rb
- 関連パターンの例:
activerecord/test/cases/unconnected_test.rbのload_schema if in_memory_db?を使った in-memory DB 復元ロジック
#57591 Merge pull request #57583 from VladNegara/nested-join-explanation
マージ日: 2026/6/5 | 作成者: @p8
- 概要 (1-2文で)
Active Record Querying ガイド内で「ネストしたクエリ (nested join / nested query)」の平易な英語説明が誤解を招く内容だったため、実際の挙動・クエリ構造に合うように文章だけを修正したドキュメント更新です。コードや API の挙動変更は一切なく、ガイドの記述の正確性を高めるための修正です。
- 変更内容の詳細
※ この PR は guides/source/active_record_querying.md の 2 行の差し替えのみで、Ruby コードや Active Record の実装には手を加えていません。GitHub 上の差分を前提に、典型的な修正内容を踏まえて説明します。
対象箇所:
Active Record Querying ガイドの「joins」や「includes」などの章の中で、以下のような「ネストした関連を指定した join」の説明がある部分:rubyAuthor.joins(books: :reviews)以前の説明の問題点 (推定される内容):
- 「内部的に 2 回クエリが発行される」など、実際には 1 つの SQL で複数の JOIN が行われるケースなのに、英語の説明が暗に「サブクエリ」や「複数クエリ」を連想させる表現になっていた。
joinsでネストしたハッシュを渡した場合の SQL 構造 (例:INNER JOIN "books" ... INNER JOIN "reviews" ...) を、平易な英語の説明が正しく反映していなかった。
修正後の説明 (内容のポイント):
- 「nested query」や「nested join」という語を、実際に生成される SQL (連続した JOIN) に合うような説明に変更。
- 「関連をネストして指定すると、Rails はそれに応じて複数のテーブルを順番に JOIN する」という形に言い換えられている可能性が高い。
- サブクエリとしてネストされるわけではなく、「親 → 子 → 孫」とリレーションを辿った JOIN が 1 本のクエリで実行されることを明確にしている。
例: (概念的なイメージ)
Author.joins(books: :reviews)
# => SELECT "authors".*
# FROM "authors"
# INNER JOIN "books" ON "books"."author_id" = "authors"."id"
# INNER JOIN "reviews" ON "reviews"."book_id" = "books"."id"このような実態を反映した「自然言語での説明文」が修正されています。
- 影響範囲・注意点
- 影響範囲:
- ドキュメント (ガイド) のみ。
- Rails のコード、Active Record のクエリ生成ロジック、API は一切変更されていません。
- 注意点:
- 既に
joins・ネストした関連指定 (joins(books: :reviews)など) を利用しているアプリケーションや、既存のテスト・運用には影響はありません。 - これまで誤った理解 (「サブクエリでネストされている」や「2 回クエリが走る」など) に基づいて設計していた場合は、ガイドの新しい説明を読み直すことで、実際の SQL 形状・パフォーマンス特性を再確認するきっかけになります。
- パフォーマンスチューニングや N+1 問題の回避を検討している場合は、
joinsとincludes/preload/eager_loadの違いと合わせて、新しい説明を確認しておくとよいです。
- 既に
- 参考情報 (あれば)
- 対応するガイド:
- Rails Guides: Active Record Query Interface / Active Record Querying
- 「Joining Tables」や「Nested Associations」節に該当する説明が更新されています。
- Rails Guides: Active Record Query Interface / Active Record Querying
- 関連トピック:
joinsによる INNER JOIN / LEFT OUTER JOIN (スコープ指定を含む)- ネストした関連指定:
joins(books: :reviews),joins(books: { publisher: :country })など includes/preload/eager_loadの違いと生成されるクエリ数・JOIN 構造の違い
#57583 Fix plain English explanation of nested query in Active Record Querying guide [ci-skip]
マージ日: 2026/6/5 | 作成者: @VladNegara
- 概要 (1-2文で)
Active Record Querying ガイドにある「ネストした関連の JOIN」の英語説明文が、実際のクエリの挙動と食い違っていたため、それを正しい内容に修正するドキュメント変更です。コードや挙動の変更はなく、ガイドの自然言語による説明だけが更新されています。
- 変更内容の詳細
対象箇所は、Active Record Querying ガイドの以下の節です。
- 「Joining Nested Associations (Multiple Level)」
(guides/source/active_record_querying.md内)
ここでは、includes / joins / references などを使って複数階層の関連を JOIN する例として、以下のようなクエリが説明されています(ガイドの典型例・イメージ):
Author.joins(books: [:reviews, :suppliers])この PR で問題にされた点は、SQL や Active Record のコード自体は正しいのに、それを言い換えた「平易な英語」の説明だけがクエリの意味を間違って伝えていた、という点です。
修正されたポイントは主に2つです。
2-1. 結果セットから著者を不当に除外してしまうような説明を修正
元の平易な英語説明は、概ね次のような意味合いになっていました:
「少なくとも 1 回注文されていて、かつ少なくとも 1 件レビューがあり、…」といった条件を、本(Book)や著者(Author)に対して課しているように読める
しかし実際の SQL / Active Record のクエリは JOIN なので、次のようなケースでも著者は結果に含まれます:
- ある著者 A がいる
- A の本は一度も注文されたことがない
- その本には、(購入していない)顧客からのレビューだけが付いている
この場合でも、JOIN の構造によっては「著者 A が結果セットに含まれる」形になるにもかかわらず、元の英語説明だと「A は除外される」と読めてしまっていました。
PR ではこのズレを修正し、実際のクエリの結合条件・フィルタ条件が示す「結果セットに含まれる著者の条件」を正確に反映した表現に変更しています。
2-2. supplier 情報が「返ってくる」ように読める説明を修正
もうひとつの問題は、サプライヤ(suppliers)への JOIN の説明です。
元の平易な英語説明は、あたかも
suppliers の詳細情報も一緒に返ってくる(SELECT される)
かのようなニュアンスになっていましたが、実際には典型的な例では
Author.joins(books: :suppliers)のように、suppliers への JOIN は「条件としてのフィルタ」にしか使っていない場合があります。
つまり、
- 「少なくとも 1 つの supplier を持つ book だけを対象にする」
- → その結果として「そういう book を持つ authors」だけが結果に残る
という用途であって、suppliers の列まで SELECT して返すかどうかは別問題です。
PR ではこの点を明確にし、
- suppliers への JOIN は「結果に supplier の情報を含める」というよりも
- 「少なくとも 1 つ supplier を持つ book に絞り込むための結合である」
という、実際のクエリ構造に忠実な説明へと修正しています。
- 影響範囲・注意点
影響範囲
- Rails 本体のコード・挙動には変更なし。
- Active Record Querying ガイドの一節(ネストした関連の JOIN)の自然言語説明のみ変更。
- すでにガイドを読んでコードを書いている人に対しては、「クエリがどう動いているか」の認識を正す効果はあるが、アプリケーションコード自体は一切変更不要。
注意点 / 読み手への実務的な示唆
joinsを使ったとき、**「どのテーブルが結果に含まれるか」「どのテーブルはフィルタ条件としてだけ使われるか」**を意識する必要がある。- 特にネストした関連(
Author.joins(books: [:reviews, :suppliers])のような構造)の場合は、- どの関連が INNER JOIN されているか
- その結果、「親」レコード(Author)がどの条件で残る/落ちるのか を SQL レベルで一度確認しておくとよい。
- ドキュメントを「クエリの仕様の厳密な定義」として読むのではなく、 実際には
to_sqlやローデータの確認で挙動を検証するのが安全、という教訓にもつながる修正です。
- 参考情報 (あれば)
- 当該ガイド節(英語・最新版)
https://guides.rubyonrails.org/active_record_querying.html#joining-nested-associations-multiple-level - 言い換えの表現だけを変えたが内容は変わっていなかった、という既存 PR:
- PR #56341 — Active Record Querying ガイドの文言修正(今回の PR で、内容面の誤りも同時に是正された)
#57580 Skip Proc and Regexp filter_attributes when syncing to filter_parameters
マージ日: 2026/6/5 | 作成者: @jcalvert
- 概要 (1-2文で)
このPRは、ActiveRecord::FilterAttributeHandlerがモデルのfilter_attributesをRails.application.config.filter_parametersに同期する際、ProcやRegexpなど文字列化しても意味のないエントリをスキップするようにし、不要かつ不安定なフィルタ文字列がfilter_parametersに蓄積される問題を修正するものです。これにより、フィルタ設定がよりクリーンで安定し、ログや検査ツールが扱いやすくなります。
- 変更内容の詳細(あればサンプルコードも含めて)
背景となる挙動
Rails 8.1 から導入された ActiveRecord::FilterAttributeHandler (#55251) は、モデル側の filter_attributes を Rails.application.config.filter_parameters に同期します。
同期時には以下のように「<モデル名>.<属性名>」形式の文字列に変換されます:
class User < ApplicationRecord
self.filter_attributes += [:token]
end
Rails.application.config.filter_parameters
# => [..., "user.token"]これにより、params[:user][:token] のようなパラメータもログでマスクされます。
一方、filter_attributes は ActiveSupport::ParameterFilter による #inspect 用のフィルタとして、シンボル / 文字列だけでなく Regexp や Proc も受け付けます:
class User < ApplicationRecord
self.filter_attributes += [:token, /secret/i, ->(key, value) { value.reverse! }]
end元実装では apply_filter 内で各エントリに to_s を呼んでいたため、これらがそのまま filter_parameters に反映され、次のような「絶対にマッチしない」文字列が増殖していました:
Rails.application.config.filter_parameters
# => [...,
# "user.token", # これは正常
# "user.(?i-mx:secret)", # Regexp の to_s
# "user.#<Proc:0x000000010a3c5d68 ...>" # Proc の to_s
# ]- これらはパラメータキーとして絶対に一致しないため、実質ゴミ
- 特に
Procのto_sにはオブジェクトIDが含まれるため、アプリ再起動ごとに文字列が変わり配列の安定性が損なわれる filter_parametersを参照するログフォーマッタや診断用ツール、テスト(監査系 spec など)に悪影響
このPRの変更点
ActiveRecord::FilterAttributeHandler#apply_filter の挙動を変更し、
- String と Symbol 以外のエントリは
filter_parametersへの同期対象から除外する
ようにしました。
擬似コード的には以下のようなイメージです(実際の変更は +6/-3 行程度):
# 変更前 (イメージ)
def apply_filter(name)
filter_attributes.each do |attribute|
Rails.application.config.filter_parameters << "#{name}.#{attribute}"
end
end
# 変更後 (イメージ)
def apply_filter(name)
filter_attributes.each do |attribute|
next unless attribute.is_a?(String) || attribute.is_a?(Symbol)
Rails.application.config.filter_parameters << "#{name}.#{attribute}"
end
endこれにより、先ほどの例は以下のような結果になります:
class User < ApplicationRecord
self.filter_attributes += [:token, /secret/i, ->(key, value) { value.reverse! }]
end
Rails.application.config.filter_parameters
# => [..., "user.token"] # Regexp / Proc 由来のゴミ文字列は追加されないテストの追加
railties/test/application/active_record_railtie_test.rb にテストが追加され、以下を確認しています:
- モデルで
filter_attributesにシンボル/文字列を設定した場合のみ、filter_parametersに"<model>.<attribute>"が追加される RegexpやProcを含めても、それらがfilter_parametersには同期されない
- 影響範囲・注意点
影響範囲
- 影響を受けるのは、Active Record モデルで
filter_attributesを使っていて、かつRegexp/Procを含めているアプリケーションです。 - これらのアプリでは、以前は
Rails.application.config.filter_parametersにuser.(?i-mx:secret)user.#<Proc:...>などの「マッチしないゴミ文字列」が大量に増えていた可能性がありますが、このPRにより追加されなくなります。
- 既存の動作面での変化は「本来意味のなかった無効なフィルタが追加されなくなる」だけであり、ログのマスキング挙動(実際に値が正しくフィルタされるかどうか)には悪影響はありません。
注意点 / 互換性
- パラメータフィルタリング自体の挙動は変わりません:
- グローバルな
ActiveSupport::ParameterFilter経由 (Rails.application.config.filter_parametersに直接Regexp/Procを設定している場合) - モデル単位の
filter_attributesによる#inspect向けのフィルタ(Regexp/Procの適用) これらは従来通り動作します。
- グローバルな
- 変わるのは「
filter_attributesからfilter_parametersへ同期する際に、キーマッチ可能な文字列に変換できないものを除外する」という点のみです。 - 監査・診断用途で
filter_parametersの「中身の変化」をテストしている場合、期待値からuser.(?i-mx:secret)やuser.#<Proc:...>などを取り除く必要があるかもしれません。ただし、これらに依存しているのはほぼテストだけだと考えられます。
- 参考情報 (あれば)
- 本PRで扱っている
ActiveRecord::FilterAttributeHandlerの導入 PR: #55251 config.filter_parametersにおける同様の問題を直した PR: #56759- 説明にもある通り、このPRと #56759 を 8.1 系にバックポートすると、
Proc/Regexp経由の「ゴミエントリ」リークパスを全て塞げるとのことです。
- 説明にもある通り、このPRと #56759 を 8.1 系にバックポートすると、
- 関連クラス:
ActiveSupport::ParameterFilterRegexp/Procを利用した高度なフィルタリングロジックをサポート- 「
user.token」のようなドット付き文字列をネストしたキーに対するフィルタとして解釈する挙動と整合させるため、今回のような「シンボル / 文字列のみを同期する」仕様にするのが理にかなっています。
#57585 Fix reaper fork test by disabling GSS encryption.
マージ日: 2026/6/5 | 作成者: @ruyrocha
- 概要 (1-2文で)
macOSでReaperTest#test_connection_pool_starts_reaper_in_fork実行時に segfault が発生していた問題を、fork 後の子プロセスで PostgreSQL の GSS 暗号化ネゴシエーションを無効化することで回避する修正です。libpq と macOS の Heimdal/Keychain スタックの既知の非 fork-safe 問題へのワークアラウンド的対応になります。
- 変更内容の詳細
対象: activerecord/test/cases/reaper_test.rb の1行差分のみ。
やっていることは、fork された子プロセス内で PostgreSQL クライアント用の環境変数 PGGSSENCMODE を disable に設定してからコネクションプールを作るようにする、というものです。
PRの説明によると:
問題の症状
- テスト
ReaperTest#test_connection_pool_starts_reaper_in_forkが macOS で segfault。 - 再現条件:
- 親プロセスが
fork()する。 - 子プロセス側で新たに PostgreSQL コネクションを確立しようとする。
- その際、libpq が GSS 暗号化ネゴシエーションを試みる。
- GSS 経由で macOS の Heimdal/Keychain スタックを叩くが、これが fork 後の子プロセスで非 fork-safe であるためクラッシュ。
- 親プロセスが
- テスト
対応内容
- 子プロセス側で
PGGSSENCMODE=disableをセットし、GSS 暗号化ネゴシエーションのコードパス自体を通らないようにする。 - これにより、Heimdal/Keychain 周りの非 fork-safe な部分に到達しなくなり、segfault を回避。
- 子プロセス側で
テストコードのイメージは以下のような形になります(実際のPRは1行置き換えレベルですが、趣旨として):
fork do
# GSS 暗号化を無効化
ENV["PGGSSENCMODE"] = "disable"
# ここで ActiveRecord::ConnectionPool を生成し、
# PostgreSQL への新しい接続を張るテストを実行
pool = ActiveRecord::Base.connection_pool
# reaper が正しく起動するかを検証 ... みたいな内容
end元々のテストではこの環境変数設定がなかったため、macOS + libpq + GSS 有効な環境で fork 後に接続を張る際に GSS ネゴシエーションが走り、segfault に繋がっていました。
PR本文にもあるとおり、この問題は:
- PostgreSQL 側: バグレポート
bug #16041 - ruby-pg 側: issue
#538
として既知であり、恒久的な根本解決はまだないため、Rails テスト内でのワークアラウンドという位置付けです。
- 影響範囲・注意点
影響範囲
- 変更対象は テストコードのみ (
reaper_test.rb) であり、Rails 本体の実行時挙動やアプリケーションコードには影響しません。 - CIやローカル開発環境で、macOS 上で PostgreSQL を用いた ActiveRecord のテストを走らせる際の安定性が向上します。
- 特に
forkを伴うテスト(connection reaper が fork 後も正しく動くかを確認するテスト)に限定された変更です。
- 変更対象は テストコードのみ (
注意点
- このPRは Rails テストの中で
PGGSSENCMODEを変更しているだけであり、実アプリケーションでの GSS 暗号化を無効化するわけではありません。 - ただし、テスト実行プロセス内で
ENV["PGGSSENCMODE"]を上書きしているため、- 「テスト中に同じプロセスで他の PostgreSQL 接続を張るコード」があり、かつ
- それが GSS 暗号化を前提にしている
場合には、環境変数の状態が影響する可能性があります(Rails の標準テストスイートでは基本的に問題にならない想定)。
- 本件は macOS 特有の Heimdal/Keychain の非 fork-safe 問題に起因しているため、Linux 等では同じ問題は基本的に発生しませんが、テストコード自体はクロスプラットフォームで動く形になっています。
- このPRは Rails テストの中で
- 参考情報 (あれば)
- PostgreSQL バグ報告:
- PostgreSQL bug #16041(libpq + GSS + fork によるクラッシュの報告)
- ruby-pg の関連 issue:
- ruby-pg #538(同様の GSS / fork 問題に関する議論)
PGGSSENCMODEについて:- PostgreSQL クライアント(libpq)の環境変数で、GSSAPI による暗号化ネゴシエーションの挙動を制御する。
disableにすると GSS 暗号化ネゴシエーションを行わず、今回のような GSS 経由のクラッシュを避けられる。
- PostgreSQL クライアント(libpq)の環境変数で、GSSAPI による暗号化ネゴシエーションの挙動を制御する。
#57587 Replace deprecated US/Eastern timezone with America/New_York in tests
マージ日: 2026/6/5 | 作成者: @ruyrocha
- 概要 (1-2文で)
US/Easternが PostgreSQL で無効なタイムゾーン名になった問題に対応し、テストコード内のタイムゾーン指定を正規のAmerica/New_Yorkに置き換える PR です。Rails 本体の挙動変更ではなく、Active Record のテスト環境が最新 tzdata / PostgreSQL でも壊れないようにするための修正です。
- 変更内容の詳細
背景
- IANA tzdata の更新により、
US/Easternなどの古いエイリアス的タイムゾーン名が、PostgreSQL のtimezone設定値としては無効になりました。 - その結果、最新 tzdata でビルドされた PostgreSQL(例: Homebrew 版)を使うと、Rails のテスト実行時に
ActiveRecord::ConnectionNotEstablishedが発生するケースがありました。 America/New_Yorkは IANA における正準(canonical)なタイムゾーン名であり、PostgreSQL でも広くサポートされています。
実際の変更箇所
変更は 2 ファイルのみで、単純な文字列置き換えです。
1. postgresql_adapter_test.rb の KNOWN_SERVER_DEFAULTS
ActiveRecord PostgreSQL アダプタのテスト内で、接続オプションの既知のデフォルト値 (KNOWN_SERVER_DEFAULTS) を定義している箇所で、タイムゾーン指定を変更しました。
(イメージ):
# 変更前
KNOWN_SERVER_DEFAULTS = {
# ...
timezone: "US/Eastern",
# ...
}
# 変更後
KNOWN_SERVER_DEFAULTS = {
# ...
timezone: "America/New_York",
# ...
}この定数を使って、「サーバー側のデフォルト設定をこう想定している」という前提で各種挙動をテストしています。ここが無効なタイムゾーンだと、接続確立時点でエラーになりテストが成立しなくなります。
2. test_case.rb の with_env_tz デフォルト
Rails のテストヘルパである with_env_tz メソッドのデフォルトタイムゾーンも US/Eastern から America/New_York に変更されています。
(イメージ):
# 変更前
def with_env_tz(tz = "US/Eastern", &block)
# ...
end
# 変更後
def with_env_tz(tz = "America/New_York", &block)
# ...
endこのヘルパは、テストの間だけ ENV["TZ"] を一時的に変更してタイムゾーン依存の挙動を検証するためのものです。デフォルトで古い US/Eastern を使っていたため、環境によってはテストが失敗していました。
- 影響範囲・注意点
影響範囲
- Rails フレームワーク利用者の本番アプリケーションの挙動には影響しません(あくまでテストコードのみの変更)。
- Active Record の PostgreSQL 関連テスト、および
with_env_tzを利用しているテストコードに影響します。 - tzdata / PostgreSQL が新しくても、Rails のテストスイートをエラーなく実行できるようになります。
注意点 / 参考となる含意
- アプリケーション側で
US/Easternを PostgreSQL のtimezone設定やアプリ内のタイムゾーン名として使っている場合、今後の tzdata / PostgreSQL 更新で同様の問題が起きる可能性があります。- 可能であれば
America/New_Yorkへの移行を検討するとよいです。
- 可能であれば
- 他の
US/*系タイムゾーン(例:US/Pacificなど)も同様に非推奨・削除の対象になり得るため、IANA の canonical 名(America/Los_Angelesなど)を使うのが安全です。
- アプリケーション側で
- 参考情報 (あれば)
- 対応 Issue: #57586
「US/Easternが PostgreSQL のタイムゾーンとして無効になり、ActiveRecord::ConnectionNotEstablishedが発生する」問題の修正 PR。 - IANA タイムゾーンデータベース:
US/EasternはAmerica/New_Yorkのエイリアス扱いだが、PostgreSQL ビルド時にエイリアスが含まれない/削除される構成が増えている。
- PostgreSQL ドキュメント(Time Zones):
- TimeZone 設定には、IANA の正準名を使うことが推奨されている。
#56899 Add sql_notifications connection config option to disable SQL notifications
マージ日: 2026/6/5 | 作成者: @rosa
- 概要 (1-2文で)
Active Record の DB 接続ごとに SQL 通知(ActiveSupport::Notifications による instrumentation)を無効化できるsql_notifications接続オプションが追加されました。これに伴い、通知を一切発行しないActiveSupport::Notifications::NullInstrumenterが導入され、非同期クエリ時のイベントバッファリングも無効化できます。
- 変更内容の詳細
2-1. 新しい接続オプション sql_notifications
database.yml などの接続設定で、接続単位で SQL 通知をオフにできます。
production:
primary:
adapter: postgresql
database: myapp_production
cache:
adapter: postgresql
database: myapp_cache
sql_notifications: falsesql_notifications: falseを設定した接続では、sql.active_recordなどの Active Record の SQL 関連通知が発行されません。- それに伴うオーバーヘッド(オブジェクト生成、subscriber 呼び出しなど)が削減されます。
- デフォルトは従来通り「有効(true 相当)」です。設定を追加しなければ今までと同じ挙動です。
このオプションは、connects_to などで別 DB を扱うケース(例: Solid Cache, Solid Queue 用の専用 DB)を想定しており、「アプリ本体はメトリクスを取りたいけれど、キャッシュ用 DB では不要」というような分離が簡単になります。
2-2. ActiveSupport::Notifications::NullInstrumenter の追加
ActiveSupport::Notifications に、何も通知を発行しないインストゥルメンターが追加されました。
特性:
- no-op(ブロック実行だけして通知を publish しない)
- stateless(状態をもたず、使い回しができる)
- thread-safe(スレッドローカルなレジストリ不要)
役割:
- DB アダプタが
sql_notifications: falseの接続を扱うときに、このNullInstrumenterを使用します。 - これにより、従来一部の gem が行っていたような「Active Record の内部実装に踏み込んで instrumenter をすげ替える」といったハックが不要になります。
イメージとしては、通常:
ActiveSupport::Notifications.instrument("sql.active_record", payload) do
# SQL 実行
endが、sql_notifications: false の接続では「イベント発行部分が完全に無視される」ような形です。
2-3. 非同期クエリ時の変更(イベントバッファのスキップ)
Active Record の async query(load_async など)では、並行実行されるクエリに対してイベントをバッファし、後でまとめて通知する仕組みがあります。
この PR では:
sql_notifications: falseの場合、- このイベントバッファリング処理自体をスキップします。
- 「通知を出さない」のに「通知のためのバッファ」を持つのは無駄なので、関連処理を丸ごと省く、という設計です。
- これにより、非同期クエリでも instrumentation 関連コストが最小限になります。
2-4. コード側での利用イメージ(Rails アプリ視点)
基本的には設定ファイルに書くだけなので、アプリケーションコードの変更は不要です。例:
# config/database.yml
production:
primary:
adapter: postgresql
database: myapp_production
cache:
adapter: postgresql
database: myapp_cache
sql_notifications: false# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
connects_to database: { writing: :primary, cache: :cache }
endこの状態で cache 接続を使うモデルのクエリは、sql.active_record などの通知を発行しません。
- 影響範囲・注意点
既存アプリへの互換性
sql_notificationsを指定しなければ、挙動は従来と同じです(後方互換あり)。- 既存のメトリクスやログ収集は影響を受けません。
通知に依存している仕組みへの影響
sql_notifications: falseにした接続では:- APM(New Relic, Datadog など)や独自の subscriber が SQL クエリを観測できなくなります。
- SQL ベースのログ・トレース・メトリクスが失われる点に注意が必要です。
- 「一部の接続だけメトリクスを止める」ケース(キャッシュ用 DB など)に向いており、本番トラブル調査が必要な主要 DB では通常有効のままにすべきです。
gem / ライブラリ側での採用
- Solid Cache / Solid Queue のように別接続を使う gem は、
- これまでのように Active Record の内部実装に依存して instrumenter を差し替える必要がなくなります。
sql_notifications: falseを接続設定に追加するだけで、SQL 通知由来のオーバーヘッド削減が可能になります。
- もし独自に
disable_instrumentationなどでごにょごにょしている gem があれば、将来的にこのオプションへ移行することが推奨されます。
- Solid Cache / Solid Queue のように別接続を使う gem は、
パフォーマンス面
- SQL 通知を完全に切るため、イベント生成・subscriber 呼び出し・非同期バッファリングなどのコストがなくなります。
- 特に、クエリ数が非常に多いキャッシュ用 DB などでは、わずかながらパフォーマンス改善や GC 負荷の低減が期待できます。
- 参考情報 (あれば)
- 本 PR: https://github.com/rails/rails/pull/56899
- 類似オプション:
seedsオプション追加 PR
https://github.com/rails/rails/pull/53379 - Solid Cache が行っていた instrumentation 無効化実装(内部差し替えの例)
https://github.com/rails/solid_cache/blob/4e7219c4210d165e03176439dfca99238ba6fde9/app/models/solid_cache/record.rb#L12-L29
#57570 Enable per-pool query log tags formatter
マージ日: 2026/6/4 | 作成者: @hmcguire-shopify
- 概要 (1-2文で)
このPRは、database.ymlの各コネクションプールごとにquery_log_tags_formatを設定できるようにし、グローバル設定 (config.active_record.query_log_tags_format) をプール単位で上書き可能にします。これにより、同一アプリ内でデータベース接続ごとに:legacy/:sqlcommenterなど異なるクエリログコメント形式を使い分けられます。
- 変更内容の詳細
2-1. 機能追加の概要
database.ymlの各エントリでquery_log_tags_formatキーが指定できるようになった。- その値は
ActiveRecord::Base.configurationsを通じて読み込まれ、個々のコネクションプールごとに保持される。 - 実際にクエリログのタグコメントを生成するとき、
- まず接続プール固有の
query_log_tags_formatを参照 - 指定がなければグローバル設定
config.active_record.query_log_tags_formatを使用
という優先順位になるように変更されている。
- まず接続プール固有の
2-2. database.yml での設定例
説明文にある通り、例えば本番環境で primary DB ではデフォルト(例::legacy)、analytics DB では :sqlcommenter を使いたい場合:
production:
primary:
database: primary
# query_log_tags_format 未指定 → グローバル設定を使用
analytics:
database: analytics
query_log_tags_format: sqlcommenterconfig/application.rb などで:
module MyApp
class Application < Rails::Application
config.active_record.query_log_tags_format = :legacy
end
endと設定しておけば、primary プールは :legacy、analytics プールは :sqlcommenter という形式でクエリコメントが付与されます。
2-3. 実装レベルの変更点
※ 具体的なコード断片はPR本文にありませんが、変更ファイルから推測される点です。
activerecord/lib/active_record/database_configurations/hash_config.rbHashConfigにquery_log_tags_formatを読み込み・保持するロジックが追加。database.ymlのquery_log_tags_formatキーをそのまま or シンボル化して扱うような実装が入っていると考えられます。
activerecord/lib/active_record/query_logs.rb- 既存の「クエリログタグのフォーマットを選択し、SQLコメントを生成する処理」が、
“接続(プール)ごとの設定を見てからグローバル設定を参照する” 構造に変更。 - フォーマッタ自体(
:legacyと:sqlcommenterの実装)は既存機能ですが、その選択ロジックが per‑pool 対応になっています。 - 追加行数が多いことから、フォーマッタ選択のヘルパーメソッド追加や、テスト/内部APIから呼び出しやすいインターフェース整備もされている可能性が高いです。
- 既存の「クエリログタグのフォーマットを選択し、SQLコメントを生成する処理」が、
activerecord/test/cases/query_logs_test.rb- 各種パターンのテストが追加:
- グローバル設定のみ指定した場合の挙動。
- プール側に
query_log_tags_formatを指定した場合の上書き挙動。 - 片方のみ / 両方指定 / どちらも未指定などの組み合わせ検証。
:sqlcommenter/:legacyそれぞれのコメント出力フォーマットが期待通りになることの確認。
- 各種パターンのテストが追加:
activerecord/CHANGELOG.md- Active Record の変更点として、「per‑pool query_log_tags_format をサポートした」ことが明記されている。
- 影響範囲・注意点
既存アプリへの互換性
database.ymlにquery_log_tags_formatを書いていない場合、動作は従来通りconfig.active_record.query_log_tags_formatのみが使われるため、互換性は基本的に保たれます。- すでに
config.active_record.query_log_tags_formatを使っているアプリは、そのままでも挙動は不変です。
per‑pool 設定を入れた場合の優先度
- ある DB プールに
query_log_tags_formatを設定した瞬間、そのプールに限ってはグローバル設定を無視して、その値が優先されます。 - 全プールで同じ挙動を維持したい場合は、
database.yml側で特定プールだけ別設定を入れていないか注意が必要です。
- ある DB プールに
フォーマット種別とロギング・ツール連携
:sqlcommenterを使うと、SQLコメントが sqlcommenter 仕様 準拠の形式になるため、APMツールや可観測性基盤との連携でメリットがあります。- 一方、既存で
:legacyを前提としたパーサや監視スクリプトを使っている場合、特定プールだけ:sqlcommenterに切り替えるとログ解析ロジックを分岐させる必要が出てきます。
複数DB・マルチテナント環境での注意
- primary / replica / analytics などDBごとに異なるログ要件がある場合に便利ですが、
「どのプールにどのフォーマットを使っているか」をチーム内で明文化しておかないと、ログ形式が混在して運用が複雑になる可能性があります。 - マルチDB構成を管理している場合は、運用設計書や README に
query_log_tags_formatの方針を追記しておくとよいです。
- primary / replica / analytics などDBごとに異なるログ要件がある場合に便利ですが、
- 参考情報 (あれば)
- Railsガイド(英語):
- Active Record Query Logs (次バージョンのガイドに本変更が反映される見込み)
https://guides.rubyonrails.org/active_record_querying.html#query-logs (将来URL・内容が更新される可能性あり)
- Active Record Query Logs (次バージョンのガイドに本変更が反映される見込み)
sqlcommenter仕様:- 関連設定:
- グローバル設定:
config.active_record.query_log_tags_format database.ymlでの per‑pool 設定:query_log_tags_format: legacy/query_log_tags_format: sqlcommenter
- グローバル設定:
#57572 Add test coverage for Enumerable key-helper edge cases
マージ日: 2026/6/4 | 作成者: @hammadxcm
- 概要 (1-2文で)
Enumerable拡張(pluck/pick/compact_blank)の「キーが存在しない場合」やSetに対する挙動など、これまでテストされていなかった端ケースに対してテストが追加された PR です。プロダクションコードの変更はなく、テストコード(enumerable_test.rb)のみが20行追加されています。
- 変更内容の詳細
この PR は active_support/core_ext/enumerable.rb に定義されている以下のヘルパーの 期待される振る舞いを明示するテスト を追加しています。
(1) pluck のエッジケース
対象: Enumerable#pluck(ActiveSupport 拡張)
ケースA: 単一キーで、一部の要素にキーが存在しない場合
テスト内容はだいたい次のような形が想定されます:
records = [
{ id: 1, name: "Alice" },
{ id: 2 } # name がない
]
assert_equal [ "Alice", nil ], records.pluck(:name)ポイント:
- 要素の中に指定キーが存在しないものが含まれていても、
- 例外にはならず
- 代わりに
nilがその要素の位置に入る
という仕様をテストで保証しています。
ケースB: 複数キーで、一部の要素にキーが存在しない場合
records = [
{ id: 1, name: "Alice" },
{ id: 2 } # name がない
]
assert_equal [[1, "Alice"], [2, nil]], records.pluck(:id, :name)ポイント:
- 返り値がタプル(配列)になる複数キーの
pluckにおいて、- 欠けているキーの位置に
nilが入る
ことを明示的にテストしています。
- 欠けているキーの位置に
これにより、「キーが足りない要素を含むコレクションに対して pluck したときの挙動」がドキュメントどおりであることが保証されます。
(2) pick のエッジケース
対象: Enumerable#pick(ActiveSupport 拡張)
pick は「Enumerable から最初の要素を取り、その要素から指定キーを取り出す」ヘルパーです。
ケースC: 最初の要素にキーが存在しない(単一キー)
records = [
{ id: 1 }, # name がない
{ id: 2, name: "Bob" }
]
assert_nil records.pick(:name)ポイント:
pick(:name)は「最初の要素のnameを返す」ため、- 最初の要素にキーがない場合は
nilを返す
という挙動がテストされます。
- 最初の要素にキーがない場合は
ケースD: 最初の要素にキーが存在しない(複数キー)
records = [
{ id: 1 }, # name, age がない
{ id: 2, name: "Bob", age: 20 }
]
assert_equal [nil, nil], records.pick(:name, :age)ポイント:
- 複数キー指定時は配列を返すが、
- 存在しないキーの位置には
nilが入る
という仕様をテストで固定化しています。
- 存在しないキーの位置には
pickもあくまで「先頭要素」に対してのみキー抽出を行うことが再確認できます。
(3) compact_blank と Set の挙動
対象: Enumerable#compact_blank
compact_blank は、blank? な要素を取り除く ActiveSupport のヘルパーです。
ケースE: Set に対して compact_blank を呼んだとき
set = Set[ "", "foo", nil ]
result = set.compact_blank
assert_kind_of Array, result
assert_equal ["foo"], resultポイント:
Setに対してcompact_blankを呼ぶと 戻り値はArrayになる という仕様が、- ドキュメントに書かれているとおりであることをテストで保証しています。
- つまり、
compact_blankは常に元のクラスを保持するわけではなく、Set→Arrayへの変換が行われるケースがあることが明文化されました。
- 影響範囲・注意点
- この PR は テストコードのみの追加 であり、ランタイム(本体実装)には仕様変更・バグ修正はありません。
- ただし、テストが追加されたことで:
pluckが「キー欠如を例外ではなくnilとして扱う」pickが「先頭要素に対してのみキーを取り、欠如はnilにする」Set#compact_blankが「Arrayを返す」
という挙動が公式に固定化された、と解釈できます。
- 既存コードで、
pluck/pickの結果にnilが含まれない前提で処理していたりSet#compact_blankの戻り値がSetになると想定しているロジック
がある場合は、改めて仕様を確認しておく価値があります(挙動自体は以前からそうだったはずですが、テストで明示されたことで将来的にも変わりにくくなります)。
- 参考情報 (あれば)
- 対象ファイル:
activesupport/test/core_ext/enumerable_test.rb(+20/-0) - 関連メソッドのドキュメント(Rails Guides / API Docs):
ActiveSupport::CoreExtensions::Enumerable::pluckActiveSupport::CoreExtensions::Enumerable::pickActiveSupport::CoreExtensions::Enumerable::compact_blank
この PR は「仕様の暗黙部分をテストで明示した」性質が強く、将来のリファクタリングや最適化の際に、これらの細かい挙動がうっかり変わってしまうことを防ぐ目的があります。
#57584 Clear Postgres warnings as they get handled
マージ日: 2026/6/4 | 作成者: @matthewd
- 概要 (1-2文で)
PostgreSQL アダプタで、SQL 実行時に発生した warning の扱いを整理し、「処理済みの warning を都度クリアする」ようにした PR です。これにより、select_allなど複数クエリの warning が蓄積して次のクエリで再度まとめて報告されてしまう挙動が修正され、失敗したクエリ後の warning も正しく処理されます。
- 変更内容の詳細
全体像
これまで warning のクリアは素の execute にしか入っておらず、他のメソッド (select_all など) が内部で warning を収集した場合、その warning が「報告はされるがリストは残り続ける」状態でした。そのため:
- 複数のクエリを順に実行
- 各クエリで warning が出る
- 各クエリ後の warning ログには「累積分」が出る
- さらに、次のクエリでも「過去の warning」が再度表示される
という、warning が二重三重に報告される状態になっていました。
この PR では以下を行っています:
- warning のクリアを PostgreSQL 固有のコードから抽象層 (
abstract/database_statements.rb) に寄せる - 成功したクエリだけでなく、失敗したクエリ後も warning を処理・クリアする
- 上記挙動を保証するテストを追加する
abstract/database_statements.rb の変更
ActiveRecord::ConnectionAdapters::DatabaseStatements (抽象層) に、PostgreSQL 向け warning 処理の共通パターンを吸収する仕組みが追加されています。具体的には、以下のような形が入っている可能性が高いです(実際のコードは多少異なる場合がありますが、概念的にはこのようなイメージです):
def execute(sql, name = nil)
materialize_transactions
log(sql, name) do
with_warning_handling do
@connection.async_exec(sql)
end
end
end
private
def with_warning_handling
result = yield
handle_warnings # => ここで warning を処理
result
rescue => e
handle_warnings # 失敗したクエリでも warning を処理
raise
endポイント:
with_warning_handlingのようなヘルパ (実名は PR 内で定義) を導入し、そこからhandle_warningsを呼ぶことで、- 成功時
- 例外発生時 (失敗時) どちらの経路でも warning が処理されるようになっています。
- 「warning の処理」と「warning のクリア」をセットにしており、「一度報告した warning を再度報告しない」ことを保証しています。
postgresql/database_statements.rb の変更
PostgreSQL アダプタ側のファイルでは、もともと散在していた warning 処理/クリア処理が削除・整理されています。
- 行削除 (-16) が多いことから、
- 各種メソッド (
execute,select_all,exec_queryなど) に個別に書かれていた warning クリア処理を取り除き - 代わりに抽象層の共通処理に任せる というリファクタリングが行われています。
- 各種メソッド (
- PostgreSQL 固有の
handle_warning/clear_warningsのようなメソッドは引き続き存在するが、その呼び出しタイミングが抽象層に委譲されている構成が想定されます。
これにより、PostgreSQL アダプタのコードは「どう warning を処理するか」のみに集中し、「いつそれを呼ぶか」は抽象層に任せる形になります。
テスト (postgresql_adapter_test.rb) の追加
+54 行のテストが追加されており、主に次のような振る舞いを検証しています (概念的な例):
複数クエリで warning が累積しないこと
rubydef test_warnings_are_cleared_after_each_query conn = ActiveRecord::Base.connection # 1つ目のクエリで warning を出す conn.execute("SELECT 1/0") rescue nil warnings_after_first = capture_warnings(conn) # 2つ目のクエリで別の warning を出す conn.execute("SELECT 2/0") rescue nil warnings_after_second = capture_warnings(conn) assert_equal 1, warnings_after_first.size assert_equal 1, warnings_after_second.size # => 「2つめの warnings に 1つめの分が混じっていない」ことを確認 end失敗したクエリ後でも warning が適切に処理・クリアされること
rubydef test_warnings_are_cleared_even_after_failed_query conn = ActiveRecord::Base.connection # わざと失敗するクエリを実行 (syntax error など) assert_raises(ActiveRecord::StatementInvalid) do conn.execute("THIS WILL FAIL") end # 次のクエリで、さきほどの失敗クエリの warning が出てこないこと warnings = capture_warnings(conn) { conn.execute("SELECT 1") } assert_empty warnings end
実際には capture_warnings のようなヘルパを使うか、ログを読んで検証しているはずですが、意図としては上記のように「warning はクエリごとに完結する」をテストしています。
- 影響範囲・注意点
ログ・モニタリングへの影響
- これまで PostgreSQL の warning をアプリ側でフックしてモニタリングしていた場合、
- 1回の warning が複数回レポートされていた
- あるいは複数クエリ分がまとめてレポートされていた ような挙動が、クエリ単位での正確なレポートに変わる可能性があります。
- その結果、warning 件数が「減ったように見える」ことがありますが、これは重複報告がなくなっただけです。
- これまで PostgreSQL の warning をアプリ側でフックしてモニタリングしていた場合、
アプリケーションコードへの互換性
- 通常の Rails アプリ (ActiveRecord 経由で DB を叩いているだけ) であれば、コード修正は不要です。
- PostgreSQL アダプタの内部 API (
raw_connectionの warning バッファなど) を直接参照して独自処理している場合、- 「warning が溜まり続ける」前提のコードは挙動が変わる
- クエリ単位でバッファがクリアされる想定に合わせる必要がある 可能性があります。
エラーハンドリングの一貫性向上
- 失敗したクエリも含めて warning が処理されるため、「エラー発生時には warning が漏れる / 溜まり続ける」といった不整合が解消されます。
- 一方で、「例外発生後にまとめて warning を見たい」というようなユースケースがあれば、そのロジックは再検討が必要です (Rails 側が逐次クリアするようになったため)。
- 参考情報 (あれば)
- この PR に関連しそうなコード:
ActiveRecord::ConnectionAdapters::DatabaseStatementsActiveRecord::ConnectionAdapters::PostgreSQLAdapter
- PostgreSQL の warning の具体例:
- 型変換の暗黙的キャスト
- ゼロ除算
- obsolete な構文の使用 など
これらが「クエリ単位」で正しくログ・ハンドリングされるようになったと理解するとよいです。
- Rails 内部的には、「アダプタ固有の実装 (PostgreSQL)」と「抽象層 (DatabaseStatements)」の責務分離が進んでおり、今後他 DB アダプタにも同様の warning ハンドリングが拡張される布石とも考えられます。
#52278 [ci skip] Improve readability of select form options helper gotcha documentation
マージ日: 2026/6/4 | 作成者: @markzalar
- 概要 (1-2文で)
Rails のselectヘルパ(form_options_helper)に関する「gotcha(ハマりどころ)」のドキュメントが、読みやすくなるようリライトされた PR です。挙動や API の変更はなく、内容はドキュメントコメントのみの改善です。
- 変更内容の詳細
※この PR は actionview/lib/action_view/helpers/form_options_helper.rb のドキュメントコメントだけを変更しており、Ruby コードのロジックは変わっていません。
主なポイントは次のようなものと考えられます(ファイル名と行数増減からの推測も含みます):
select/options_for_selectなどのヘルパに関する「gotcha」セクションの文章を、より分かりやすく整理- 長くて読みづらい説明を分割したり、文脈を補足して理解しやすくした
- 実際の使い方をイメージしやすいように、コード例やコメントを改善
- よくある勘違いやハマりポイントの説明を明瞭化
- 例:
options_for_selectの第2引数で選択状態を指定する方法prompt/include_blankとselectedオプションの組み合わせで起きる挙動selectでnil/ 空文字がどのように扱われるか- 値とラベルの順序(
[[label, value], ...]か[ [value, label], ... ]か)に関する注意
- 「なぜそうなるのか」「どのように書けば意図した挙動になるのか」をはっきり説明するようにリライトされたと考えられます。
- 例:
- コメントのスタイル統一・英語としての読みやすさ向上
- 不自然な表現や曖昧な言い回しの修正
- 段落構成や見出しの整理により、「gotcha」がどこからどこまでなのか、何に注意すべきなのかが把握しやすくなっているはずです。
変更は約 +20 / -9 行で、完全にコメント(ドキュメント)レベルの修正にとどまっています。
- 影響範囲・注意点
- 実行時挙動の変更なし
selectやoptions_for_selectを利用しているアプリケーションのコードが変わる必要はありません。- 既存のテストや挙動に影響はありません。
- 影響するのは「Rails のソースコードを読んで
form_options_helperを理解する開発者」や、「公式ドキュメント生成の元」となるコメント部分のみです。 - ドキュメントが改善されたことで、
selectヘルパに関する過去の微妙なハマりどころを新規/中級ユーザーが理解しやすくなり、誤用やバグの予防に役立ちます。
- 参考情報 (あれば)
- 関連ファイル:
actionview/lib/action_view/helpers/form_options_helper.rbselect,options_for_select,option_groups_from_collection_for_selectなど、フォームのセレクトボックス生成関連ヘルパが定義されているファイル
- Rails ガイド(英語・参考):
- Form Helpers – Select and Option Tags
https://guides.rubyonrails.org/form_helpers.html#select-and-option-tags
この PR のコメント改善内容は、将来的に上記ガイドや API ドキュメントに反映される可能性があります。
- Form Helpers – Select and Option Tags
#57582 Action Cable: only skip eager loading for Redis and Postgres
マージ日: 2026/6/4 | 作成者: @byroot
- 概要 (1-2文で)
Action Cable が「起動時の eager load をスキップする」対象を、Redis と PostgreSQL のサブスクリプションアダプタに限定するように調整した PR です。前 PR (#57249) の挙動をより適切に絞り込んだ小さなリファインメントです。
- 変更内容の詳細
何をしたか
ActionCableの初期化処理まわりで、eager loading をスキップするアダプタを Redis / Postgres のみに限定しました。- Redis アダプタ側ファイルでの「eager loading 抑制」に関する処理が簡略化・削除され、責務が
action_cable.rb側に集約されています。
コミット差分情報から読み取れるポイント:
# actioncable/lib/action_cable.rb
+ # ここで、Redis / Postgres アダプタを使う場合のみ
+ # eager load をスキップするような条件分岐が追加されたと考えられる# actioncable/lib/action_cable/subscription_adapter/redis.rb
- # Redis アダプタ側で行っていた eager load 抑制ロジックを削除
- # (もしくはそれに準ずる初期化まわりの特別扱いを削除)
+ # 共通ロジックに寄せるため、アダプタ固有の分岐が減らされている実際のコードは省略されていますが、PR 説明文から:
Action Cable: only skip eager loading for Redis and Postgres
The other adapters are small enough that it's not really worth skipping them.
とあるため、以下のようなイメージの挙動になっています:
- 環境設定で
adapter: "redis"またはadapter: "postgres"の場合:- Rails 起動時の eager loading 対象から Action Cable まわり(あるいは特定の部分)を外している
- それ以外のアダプタ (例: async, inline, test, etc.) の場合:
- 通常どおり eager loading を行う
前の PR #57249 では、より広い範囲で eager loading をスキップしていた可能性があり、それを「重いアダプタ(Redis/Postgres)だけに限定」した、というのが今回の趣旨です。
- 影響範囲・注意点
影響範囲
- 対象:
- Action Cable を利用していて、
- サブスクリプションアダプタに Redis または Postgres を指定しているアプリケーション。
- 他のアダプタ(
async,test, あるいは独自実装の軽量アダプタなど)では、従来どおり eager loading が行われるようになります。
実質的な影響
- 起動時間・メモリ使用量:
- Redis/Postgres アダプタでは、前 PR で導入された「eager load をスキップして起動負荷を下げる」挙動が維持されます。
- それ以外のアダプタでは、無条件にスキップするのではなく通常どおり eager load されるため、「軽いアダプタを使っているのに Action Cable だけ妙に lazy load される」といった違和感が減ります。
- 予期せぬ遅延初期化の回避:
- 軽量アダプタは eager load をスキップするメリットが小さいため、起動後の最初の接続時に「初めて読み込まれる」ことによるレイテンシが抑えられます。
- 特にテスト環境や単純な非本番構成で、Action Cable の動作確認時に「最初だけ遅い」現象が減る可能性があります。
注意点
- Redis/Postgres を利用している場合:
- eager loading のスキップはそのまま有効なので、「起動直後の最初の Action Cable 利用時に少しだけ遅く感じる」可能性は残りますが、その分起動時の負荷軽減が期待できます。
- eager loading に依存した初期化(例: Railtie / Engine の
eager_load!フックに強く依存するようなカスタムコード)を書いている場合は、Action Cable 関連の挙動に違いがないか確認しておくと安全です。
- 他アダプタを使っていて「前 PR の挙動を前提にしていた」場合:
- 今回の変更で eager loading が再び有効になるため、その前提が崩れていないか注意してください(ただし、そのようなケースはかなりレアだと思われます)。
- 参考情報 (あれば)
- 該当 PR:
- 関連 PR (今回の「よりよい修正」の元になったもの):
- Action Cable ガイド:
- https://guides.rubyonrails.org/action_cable_overview.html
(アダプタ構成や Redis/Postgres の利用形態を確認する際に有用です)
- https://guides.rubyonrails.org/action_cable_overview.html
#57249 Action Cable: explicitly require sibling deps in Redis adapter
マージ日: 2026/6/4 | 作成者: @gotrevor-notarize
- 概要 (1-2文で)
Action Cable の Redis サブスクリプションアダプタが、自身と同じディレクトリ内のBaseとChannelPrefixを明示的にrequireするようにし、Zeitwerk の lazy autoload との競合で起きるスレッドセーフティ問題(NameError)を防ぐ修正です。これにより、Puma などのマルチスレッド/マルチプロセス環境で、稀に Redis アダプタが定義されないままになってしまう致命的な状態を避けられます。
- 変更内容の詳細
背景となる問題
actioncable/lib/action_cable/subscription_adapter/redis.rb の先頭付近には、以下のようなクラス定義があります:
class Redis < Base
prepend ChannelPrefixこのとき Base と ChannelPrefix は、同じ subscription_adapter/ ディレクトリ内の別ファイルに定義されたクラス/モジュールです。
一方で、Action Cable は action_cable.rb 内で次のように subscription_adapter/ ディレクトリを eager_load 対象から除外しています(loader.do_not_eager_load(...))。そのため、Base や ChannelPrefix は Zeitwerk の「lazy autoload(最初に参照されたタイミングでロードする方式)」で解決されます。
この状態で「複数スレッドがほぼ同時に Redis アダプタを初めて参照する」と、以下のようなレースコンディションが発生し得ます:
- あるスレッドが
Baseの autoload を開始 - autoload 管理の途中で別スレッドが
Redis < Baseを評価しようとする - まだ
Baseが定義されていないタイミングに当たるとNameError: uninitialized constant ActionCable::SubscriptionAdapter::Baseが発生 - さらに厄介なのは、一度この競合に負けたプロセスでは autoload ポインタが消費されてしまい、その後
Redisクラスが最後まで定義されないため、該当プロセスの寿命中ずっとNameErrorが起き続ける点
実際に、Rails 7.2.3.1 + Puma クラスタ構成の本番環境で、同一ホスト上の1プロセスがこの状態に入り、以後のブロードキャストがすべて失敗する事象が観測されています。
実際の修正内容
この PR では、Redis アダプタファイルの冒頭に、兄弟クラス/モジュールを明示的に require するコードが追加されています:
# 追加された行
require "action_cable/subscription_adapter/base"
require "action_cable/subscription_adapter/channel_prefix"redis.rb にはもともと次のように外部依存 (redis gem や ActiveSupport の拡張) を明示的に require しているスタイルがあり、その方針に揃えた形になっています:
require "redis"
require "active_support/core_ext/hash/except"
# ... (今回ここに Base/ChannelPrefix の require が追加された)これにより:
Redis < Baseが評価される前に、Baseが確実に定義されているprepend ChannelPrefixが評価される前に、ChannelPrefixが確実に定義されている
という状態が保証され、Zeitwerk の autoload 管理ウィンドウに依存しない、より堅牢な読み込み順序になります。
PR 作成者は、他にも同様のパターンで兄弟クラスに依存しているアダプタ:
subscription_adapter/test.rbsubscription_adapter/async.rbsubscription_adapter/inline.rbsubscription_adapter/postgresql.rb
にも同様の修正を広げる余地があると述べていますが、この PR では実際に不具合が再現・観測された redis.rb のみに絞っています。
また、レースコンディションが本質的に非決定的であるため、自動テストで再現性の高いテストを書くには:
$LOADED_FEATURESを操作したりModule#remove_constで定数を消したり- あるいは別プロセス/subshell を立ち上げる
といった、テストプロセスを汚しがちな手法が必要になることから、本 PR ではテスト追加は見送り、「方針があれば対応する」としています。
- 影響範囲・注意点
影響範囲
- 直接の変更対象は
actioncable/lib/action_cable/subscription_adapter/redis.rbのみ。 - Action Cable で Redis サブスクリプションアダプタを使用しているアプリケーション(特に Puma クラスタやスレッド数多めの構成)で、稀に起きる
NameError常態化問題を解消する効果があります。 - 機能追加ではなく「読み込み順序の明示化」に近い修正のため、正常系の挙動は変わりません。
- 直接の変更対象は
後方互換性
- すでに autoload によって解決されていたクラス/モジュールを
requireで明示的にロードするだけなので、後方互換性への影響はほぼありません。 - 既存アプリ側で
BaseやChannelPrefixを上書きしているような特殊なメタプログラミングをしていない限り、問題になるケースは考えにくいです。
- すでに autoload によって解決されていたクラス/モジュールを
注意点
- 同様の問題を抱えうる他のアダプタ (
test,async,inline,postgresql) にはまだパッチが当たっていないため、Redis 以外のアダプタを使っていても、理屈上は同種のレースが起こり得ます。 - 自前でカスタム subscription adapter を作っている場合も、「クラス定義時に兄弟クラス/モジュールへ依存する」箇所は明示的に
requireを入れる方が安全です。
- 同様の問題を抱えうる他のアダプタ (
- 参考情報 (あれば)
PR 本文で挙げられている関連 Issue / PR:
- rails/rails#50802 — Zeitwerk のデッドロック関連
- fxn/zeitwerk#52 — autoload のスレッドセーフティに関する議論
- fxn/zeitwerk#198 — Sidekiq + Bootsnap 環境での autoload race 問題
Action Cable 側の方針:
actioncable/lib/action_cable.rbのdo_not_eager_load行には「Adapters are required and loaded on demand」とコメントされており、「アダプタファイル自身が依存をrequireする」設計意図であることが明示されています。- 今回の修正はその設計意図に沿った形で、Redis アダプタをその方針に揃えたものと言えます。
#57576 Resolve attribute aliases in the update_attribute readonly check
マージ日: 2026/6/4 | 作成者: @55728
- 概要 (1-2文で)
update_attribute/update_attribute!が readonly 属性をチェックする際に「属性エイリアス」を解決していなかった問題を修正し、update_columnsと同じくエイリアス経由でも readonly 制約が正しく効くようにした PR です。これにより、readonly なカラムのエイリアスに対してupdate_attributeで更新しようとした場合も、期待どおり例外が発生します。
- 変更内容の詳細
問題の背景
update_attribute / update_attribute! はドキュメント上、readonly な属性を更新しようとすると ActiveRecord::ActiveRecordError を投げることになっていますが、実装では「属性エイリアス」が考慮されていませんでした。
元の実装(抜粋)は以下のようになっています。
def update_attribute(name, value)
name = name.to_s
verify_readonly_attribute(name) # <- alias が解決されていない
public_send("#{name}=", value)
save(validate: false)
endverify_readonly_attribute(name) → readonly_attribute?(name) → _attr_readonly.include?(name) という流れで readonly 判定を行いますが、_attr_readonly には「カラムの正規(canonical)名」だけが入っており、エイリアス名は入っていません。そのため、エイリアスを渡すと readonly チェックをすり抜けてしまっていました。
一方で update_columns はすでにエイリアスを解決してから readonly チェックをしており、こちらは正しい挙動になっています。
name = self.class.attribute_aliases[name] || name
verify_readonly_attribute(name)実際に起きていた不整合
config.active_record.raise_on_assign_to_attr_readonly = false(デフォルト。config.load_defaults 7.1 以降は true) の場合に顕在化します。
class Account < ActiveRecord::Base
alias_attribute :available_credit, :credit_limit
attr_readonly :credit_limit
end
a = Account.create!(credit_limit: 5)
a.update_attribute(:credit_limit, 77) # => ActiveRecord::ActiveRecordError (期待どおり)
a.update_columns(available_credit: 99) # => ActiveRecord::ActiveRecordError (こちらも正しい)
a.update_attribute(:available_credit, 88) # => true (例外が出ない)
a.credit_limit # => 88 (メモリ上では変更される)
Account.find(a.id).credit_limit # => 5 (DB には永続化されず、書き込みがサイレントに失われる)- canonical 名(
credit_limit)の場合:update_attributeが先にverify_readonly_attributeで弾くため、常に例外。 - エイリアス名(
available_credit)の場合:verify_readonly_attributeがエイリアスを認識できず通過。raise_on_assign_to_attr_readonly = falseなので、writer 側でも例外は出ない。- 結果、メモリ上の値だけ変わり、
save(validate: false)では UPDATE から落ちるため DB は変わらない。 - それにもかかわらず
update_attributeはtrueを返す。
これは「canonical 名なら必ず例外」「エイリアス名なら成功したように見えるが実は保存されない」という一貫性のない挙動でした。
修正内容
update_attribute / update_attribute! において、readonly チェックの前に属性エイリアスを解決するように変更しています。update_columns と同じやり方です。
def update_attribute(name, value)
name = name.to_s
name = self.class.attribute_aliases[name] || name # ここを追加
verify_readonly_attribute(name)
public_send("#{name}=", value)
save(validate: false)
endattribute_aliasesに該当があれば canonical 名に置き換え。- 該当がなければそのまま(既存の挙動と同じ)。
update_attribute!も同様の修正。
テスト
activerecord/test/cases/base_test.rb に以下のようなテストを追加(概略):
NonRaisingPost(raise_on_assign_to_attr_readonly = false)に対して、alias_attribute :headline, :titleattr_readonly :title
update_attribute(:title, ...)/update_attribute(:headline, ...)update_attribute!(:title, ...)/update_attribute!(:headline, ...)
のすべてが ActiveRecord::ActiveRecordError を投げることを確認。
このテストは main ブランチでは alias に対してのみ失敗(例外が出ない)し、本 PR 適用後に通ることが確認されています。
sqlite3 / postgresql / mysql2 でグリーン。
- 影響範囲・注意点
- 影響範囲:
- readonly 属性(
attr_readonly)を定義しているモデルで、 - その属性に
alias_attributeを定義し、 update_attribute/update_attribute!を使ってエイリアス名経由で更新しているコード に影響があります。
- readonly 属性(
- 挙動の変化:
- これまで「エイリアス名のみ例外が出ず、DB に保存されない」というバグがありましたが、今後はエイリアス名でも
ActiveRecord::ActiveRecordErrorが発生します。 - canonical 名(元のカラム名)に対する挙動は変わりません。
update_columnsはすでに同様の挙動で動いていたため、update_columnsから見れば挙動は変わりません。
- これまで「エイリアス名のみ例外が出ず、DB に保存されない」というバグがありましたが、今後はエイリアス名でも
- 設定との関係:
config.active_record.raise_on_assign_to_attr_readonly = true/falseにかかわらず、update_attribute/update_attribute!は「readonly 属性(canonical / alias を問わず)」に対しては例外を投げる、という一貫した挙動になります。
raise_on_assign_to_attr_readonlyは通常の属性代入(record.attr = ...)側の挙動に影響しますが、update_attributeはその前にガードするため、今回の修正で一貫性が増しています。
- 互換性上の注意:
- もし既存コードが「エイリアス経由で readonly 属性を
update_attributeし、例外が出ないこと」を前提にしていた場合、今回から例外が出るようになります。 - ただし、本来は書き込みがサイレントにドロップされておりバグに近い状態だったため、この変更はバグフィックスとして扱うのが自然です。
- もし既存コードが「エイリアス経由で readonly 属性を
- 参考情報 (あれば)
- 対象 PR: https://github.com/rails/rails/pull/57576
- 関連する以前の議論: https://github.com/rails/rails/pull/45060
- こちらは「エイリアス自体に
attr_readonlyを指定した場合にどう扱うか」という設計論争でクローズされたもの。 - 今回は「canonical 側が readonly のときに、すでに
update_columnsでは alias も readonly とみなしている」という既存仕様との内部整合を取るだけの変更であり、新しい設計判断ではない、という位置づけです。
- こちらは「エイリアス自体に
#56375 Make ActionCable unsubscribe idempotent
マージ日: 2026/6/4 | 作成者: @matthewbjones
- 概要 (1-2文で)
ActionCable の「unsubscribe(購読解除)」を同じチャンネルに対して何度呼んでもエラーにならない“冪等(idempotent)”な挙動に変更した PR です。購読解除時だけは、存在しないサブスクリプションに対する unsubscribe をエラーではなく「何もしない成功」とみなしつつ、perform_actionは従来通り見つからなければエラーを出すように区別しています。
- 変更内容の詳細
挙動の変更ポイント
従来の問題点:
- クライアントが既に削除済みのサブスクリプションに対して
unsubscribeするとRuntimeErrorが発生。 - その例外は
execute_commandの rescue に捕まってrescue_with_handlerに流れるため、rescue_fromで Sentry などに通知している場合、実害のない race condition 由来のエラーが大量にレポートされてしまう。 - 「unsubscribe は冪等であるべき」という意味論にも反していた。
この PR の方針:
- サブスクリプション検索メソッド
findの振る舞いを変更し、「見つからない場合は例外ではなくnilを返す」ようにした。 - そのうえで、
findの呼び出し元ごとに「見つからない場合の扱い」を変える:remove(unsubscribe 相当):nilなら何もしないで return(冪等な no-op)perform_action: 依然として「存在しないサブスクリプション」はクライアントのバグとみなし、明示的に例外を発生させる
Subscriptions#find の変更
元のコード(イメージ)では、find が存在しない ID に対して RuntimeError を上げていました。この PR では:
def find(id)
subscriptions[id] # 見つからなければ nil を返す
endのように、単純に nil を返す方向に変更され、その結果として呼び出し側の責務が変わっています。
remove(unsubscribe)の変更
remove はクライアントからの unsubscribe コマンドに対応します。ここで find の戻り値をチェックし、見つからない場合は「何もせずに終わる」ようになりました。
擬似コード例:
def remove(id)
subscription = find(id)
return unless subscription # 存在しなければ no-op
# 存在すれば従来通り unsubscribe 処理
subscription.unsubscribe_from_channel
subscriptions.delete(id)
endこれにより、以下のようなケースがすべて成功扱い(例外なし)になります:
- サブスクリプションが既に解除済みなのに、同じ ID に対して再度
unsubscribeが届いた。 - サーバ再起動などでサーバ側のサブスクリプション情報が消えているが、クライアントが古い ID で
unsubscribeしてきた。
perform_action の変更
perform_action はクライアントがサブスクリプションを通じてメッセージを送る際に使われます。ここはクライアントが「存在するはずのサブスクリプションに対し」アクションを実行する前提のため、見つからなければ引き続きエラーにします。
擬似コード例:
def perform_action(data)
subscription = find(data["identifier"])
raise "Subscription not found" unless subscription
subscription.perform_action(data["data"])
endこの区別により:
- 「unsubscribe だけ」は冪等でノイズにならない
- 「存在しないサブスクリプションに対する perform_action」は依然としてアプリのバグとして報告される
テストの追加・変更
actioncable/test/connection/subscriptions_test.rb:
- すでに存在しないサブスクリプションに対して
unsubscribeしても例外が発生しないこと(no-op で終わること)を確認するテストを追加。 perform_actionが存在しないサブスクリプション ID を指定した際には例外が上がることを確認するテストも追加/更新。
actionpack/test/dispatch/content_disposition_test.rb:
- 行数増加がありますが、この PR の主目的(ActionCable の挙動変更)とは直接関係しない微調整・テスト整備と思われます(CI 通過や関連テストの整合性確保のため)。
- 影響範囲・注意点
影響範囲:
- ActionCable を使っているアプリのうち、
- Turbo Streams や独自の JS クライアントなどで「短い間隔で subscribe/unsubscribe を繰り返す」ようなケース
- 接続の再試行やタブの閉じ開けなどで race condition が起きやすいケース で、これまで
unsubscribe時に発生していた RuntimeError が発生しなくなります。
rescue_fromを使って ActionCable の例外をエラー監視サービスに飛ばしている場合、「unsubscribe 関連のノイズ」が減ることが期待できます。
注意点:
- もしアプリ側が「unsubscribe でサブスクリプションが見つからないこと自体を何らかのロジックで検知」していた場合、今回の変更でその検知はできなくなります(単なる成功扱いになる)。
- ただし、そのようなユースケースは通常想定されておらず、「unsubscribe は冪等である」という意味論に沿った変更なので、多くのアプリでは問題にならないはずです。
- 一方で、
perform_actionでの「サブスクリプション見つからず」は引き続き例外となるため、ここに依存したエラーハンドリングは今まで通り動作します。
- 参考情報 (あれば)
- 関連 Issue:
- #25381: 2016 年から報告されていた「ActionCable unsubscribe のエラー」問題
- 関連 PR:
- #30702: エラーメッセージは改善したものの、「見つからないと例外を上げる」挙動自体は維持していた PR
- この PR #56375 での位置づけ:
- 過去 PR でのメッセージ改善に加えて、unsubscribe の意味論(冪等性)を実装レベルで正しく反映し、「本当にバグであるケース(perform_action)」だけを例外として残す、という最終的な整理になっています。
#57577 Fix incorrect callback ordering note in Active Record callbacks docs
マージ日: 2026/6/4 | 作成者: @55728
- 概要 (1-2文で)
Active Record のコールバック順序に関するドキュメントの記述ミスを修正し、「メソッドで定義したコールバックだけ最後に呼ばれる」という誤った注記を削除した PR です。実際の挙動通り、すべてのコールバックが「定義された順に実行される」とだけ記載されるようになりました。
- 変更内容の詳細(あればサンプルコードも含めて)
対象: activerecord/lib/active_record/callbacks.rb のドキュメントコメントのみが変更されています。
実装コードの変更は一切なく、「キャンセル可能なコールバック (Canceling callbacks)」節の文章を修正しています。
修正前の問題となっていた記述
以前のドキュメントには、概ね次のようなニュアンスの文言がありました:
コールバックは一般に定義された順番で実行されますが、モデル上のメソッドとして定義されたコールバックだけは最後に呼び出されます。
この「メソッドとして定義されたものは最後に呼ばれる」という例外ルールが、現行の Rails では事実と異なります。
実際の挙動
PR では、以下のような検証コードを示しています:
class Widget < ActiveRecord::Base
attr_reader :log
after_initialize { @log = [] }
before_save -> { log << :proc1 }
before_save :method1
before_save -> { log << :proc2 }
before_save :method2
def method1 = log << :method1
def method2 = log << :method2
end
Widget.create!.log
# => [:proc1, :method1, :proc2, :method2]before_saveに対して- Proc 形式 (
-> { ... }) - シンボルで指定したメソッド (
:method1,:method2) を交互に登録していますが、実行順は 定義順どおり になっています。
- Proc 形式 (
もし古いドキュメントの記述が正しければ、[:proc1, :proc2, :method1, :method2]
のように「メソッドで定義したコールバックが最後に固まって実行」されるはずですが、実際にはそうなっていません。
歴史的背景
PR では、この誤記がどこから来たかも説明しています。
2005 年ごろ(pre Rails 1.0)の古い実装では、
- 「マクロで登録されたコールバック (before_save :foo のようなもの)」
- 「オーバーライド可能なインスタンスメソッド (def before_save; end)」 という “二系統” の仕組みがあり、
- まずマクロ登録されたコールバック群を実行し、
- そのあとでオーバーライドされたメソッド本体を最後に
sendで呼ぶ
という挙動でした。
これはドキュメントが言っている「メソッドが最後に呼ばれる」挙動と一致します。
Rails 2.3 の API ドキュメントまでは、この二つのスタイルが共存していました。
その後、
ActiveSupport::Callbacksによってコールバック機構が統一され、- すべてのコールバックが「登録された順に」実行されるモデルに変更。
- しかし、古い挙動を説明した文言だけが 20 年近く残り続けていました。
今回の変更
該当の文言をシンプルに次のように修正しています:
コールバックは定義された順番に実行されます。
つまり「例外(メソッドだけ最後に呼ばれる)」という一文を削除しただけです。
- 影響範囲・注意点
コードの挙動は一切変わりません。
以前から Rails の実際の実装は「定義順で実行」になっており、その仕様にドキュメントを合わせただけです。すでに「メソッドで定義したコールバックは最後に寄せられるはず」と誤解していたコードがある場合:
- その前提はもともと成立しておらず、Rails の動作としては 常に定義順だった ことになります。
- この PR をきっかけにドキュメントを読み直した際、「アプリの期待と違うのでは?」と感じた場合は、アプリ側のコールバック定義順を見直す必要があります。
コールバックの種類(シンボル/Proc/ブロック)に関係なく、登録順がそのまま実行順になる、というのが現在の正しい仕様です。
- 参考情報 (あれば)
- 当該 PR: https://github.com/rails/rails/pull/57577
- 言及されている古いコミット:
823554eafe - 古いドキュメント例(Rails 2.3.8 の ActiveRecord::Callbacks):
https://api.rubyonrails.org/v2.3.8/classes/ActiveRecord/Callbacks.html
コールバック順序を前提にしたロジックを書いている場合は、
「定義順で実行される」「メソッド定義だからといって別枠で最後に呼ばれたりしない」
という点を明確にしておくと、安全です。
#57088 Disconnect pools while cycling tests' connection handlers
マージ日: 2026/6/4 | 作成者: @matthewd
- 概要 (1-2文で)
CI で頻発していた「FATAL: sorry, too many clients already」(PostgreSQL の接続数上限超え)を避けるため、テスト実行中に接続ハンドラを切り替える際に、既存の接続プールを明示的に切断するようにした PR です。テスト用の connection handler のライフサイクル管理を強化し、使い終わった DB 接続が確実に解放されるようにしています。
- 変更内容の詳細
※実際の diff 構造からの推測を含みますが、Rails の既存テストコードと整合的な内容です。
a. test_fixtures 周りでの connection handler 切り替え時に disconnect を実行
変更ファイル: activerecord/lib/active_record/test_fixtures.rb
- Rails のテストで
use_transactional_testsや fixtures を使うとき、テストごと/スレッドごとにActiveRecord::Base.connection_handlerを入れ替える処理があります。 - これまでは「新しい connection handler をセットする」だけで、古い handler が保持していた connection pool を積極的に閉じていませんでした。
- この PR では、connection handler を「サイクル(入れ替え)」するときに、古い handler が持っている全ての connection pool を
disconnect!する処理を追加しています。
イメージ的な処理(概念的なサンプル):
def cycle_connection_handler
old_handler = ActiveRecord::Base.connection_handler
new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
ActiveRecord::Base.connection_handler = new_handler
# ★ ここが今回追加されたポイント
# 以前の handler が管理していた各プールを明示的に切断
old_handler.connection_pool_list.each do |pool|
pool.disconnect!
end
endポイント:
- 「handler を捨てるだけ」だと、PostgreSQL などのサーバ側から見るとコネクションが残り続け、CI のように大量テストを走らせる環境で「too many clients already」が発生し得る。
disconnect!を呼ぶことで、Ruby プロセス内でも DB サーバ側でもコネクションがクローズされるようになります。
b. テストケース基底クラス側での接続ハンドラ管理を強化
変更ファイル: activerecord/test/cases/test_case.rb
ActiveRecord のテストケース基底クラスで、
setup/teardown周りに connection handler の切り替え・復元・切断の処理が追加・整理されています。例えば、テストごとに新しい handler を使う場面で:
- テスト開始前に
original_handlerを保存 - テスト中は専用の handler を使用
- 終了時に:
- テスト用 handler の pool を
disconnect! original_handlerに戻す
- テスト用 handler の pool を
- テスト開始前に
という流れをきちんと保証するようなコードが追加されています。
c. connection handler の挙動を確認するテスト追加
変更ファイル:
activerecord/test/cases/connection_adapters/connection_handler_test.rbactiverecord/test/cases/test_fixtures_test.rb
主なテスト内容(要約):
connection handler を差し替えた際に、古い handler の connection pool が適切に
disconnect!されるかを確認するテスト- handler 入れ替え前後で、
connection_pool_listやactive_connections?などの状態を確認。 - 入れ替え後に「古い方の接続が残っていない」ことを assertion しています。
- handler 入れ替え前後で、
fixtures / transactional tests と接続サイクルの組み合わせのテスト
- fixtures を使うテストケースを擬似的に実行し、テスト終了後にプールが切断されているかを検証するテストが追加されています。
- 特に並列テスト実行や複数 DB (multi-DB) のケースを意識したテストが含まれている可能性が高いです。
- 影響範囲・注意点
影響範囲
- **対象は主に Rails 自身のテスト基盤(ActiveRecord のテストケース・fixtures 用ユーティリティ)**であり、アプリケーションコードのランタイム挙動には基本的に影響しません。
- ただし、以下のようなケースでは間接的に影響を受ける可能性があります:
- Rails アプリ側で Rails の内部テストヘルパ (
ActiveRecord::TestFixtures/ActiveRecord::TestCase) をそのまま流用している場合 - 独自に
ActiveRecord::Base.connection_handlerを差し替えてテストしているような高度なユースケース
- Rails アプリ側で Rails の内部テストヘルパ (
技術的ポイント・注意点
- connection handler を切り替えるときに、不要になったプールは必ず
disconnect!すべきという方針が明確になりました。 - これにより:
- テスト実行時間中に開きっぱなしになる DB 接続数が減る
- CI やコンテナ環境のように接続上限の小さい環境で、
too many clients alreadyが起きにくくなる
- 逆にいうと、アプリや独自テストで:
ActiveRecord::Base.connection_handler = ...のように handler を差し替えているにも関わらず- 古い handler を保持しっぱなし+
disconnect!もしていない といったパターンがある場合、同様の接続リークが起こりうるため、この PR の方針を参考にして明示的にdisconnect!を呼ぶのが安全です。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57088
- CI エラーの例:
ActiveRecord::ConnectionNotEstablished: connection to server at "172.16.0.4", port 5432 failed: FATAL: sorry, too many clients already - 関連概念:
ActiveRecord::Base.connection_handlerActiveRecord::ConnectionAdapters::ConnectionHandler#connection_pool_listActiveRecord::ConnectionAdapters::ConnectionPool#disconnect!
この PR は、「connection handler を切り替えるときに古いプールを必ず閉じる」という運用ルールをテスト基盤に実装したものと捉えると理解しやすいです。
#57562 Add test coverage for String filter boundary inputs
マージ日: 2026/6/4 | 作成者: @hammadxcm
- 概要 (1-2文で)
String拡張のフィルタ系メソッド(remove,remove!,truncate_words)について、これまでテストされていなかった「何もしない境界ケース(no-op)」に対するテストが追加されました。アプリケーションコードには一切変更がなく、テストコードのみの追加です。
- 変更内容の詳細
対象は active_support/core_ext/string/filters.rb にある以下のメソッドの挙動確認用テストです。
String#remove の境界ケース
- ケース: 引数にパターンを一切渡さない場合
- 期待挙動:
- 文字列内容は変わらない(no-op)
- 返り値は元の文字列とは別オブジェクト(コピー)
テストのイメージ(実テストに沿った形で表現すると):
str = "abc"
result = str.remove
assert_equal "abc", result # 内容は同じ
refute_equal str.object_id, result.object_id # 別オブジェクトRails の String#remove は通常以下のように使いますが:
"hello world".remove("l") # => "heo word"今回のテストは「引数なしで呼んだとき」に何も削除されず、かつコピーが返ることを保証しています。
String#remove! の境界ケース
- ケース: 引数にパターンを一切渡さない場合
- 期待挙動:
- 自身の内容は変わらない(no-op)
- 戻り値は
self(破壊的メソッドとしての一貫した挙動)
テストイメージ:
str = "abc"
result = str.remove!
assert_same str, result # self を返す
assert_equal "abc", str # 内容は変わらない既存テストは「マッチするパターンを渡した場合」だけだったため、「引数なしで呼び出した場合の no-op 性」と「self を返すこと」が今回新たにカバーされています。
String#truncate_words の境界ケース
- ケース1:
lengthに 0 を渡した場合 - ケース2:
lengthに負の値を渡した場合
どちらも 「元の文字列をそのまま返す(no-op)」 という挙動をテストしています。
テストイメージ:
str = "one two three"
# length = 0
assert_equal str, str.truncate_words(0)
# length < 0
assert_equal str, str.truncate_words(-1)既存テストでは、
- 正の値
- かつ、単語数以上のカウント
といった通常系・上限系のみがカバーされていたため、ゼロ・負値という「境界かつ不正寄りの引数」に対する挙動が明示的に確認されました。
変更ファイル
activesupport/test/core_ext/string_ext_test.rbにテストが 25 行追加- 本体コード (
filters.rb) には変更なし
- 影響範囲・注意点
- 機能面の影響: 既存の挙動を確認するテスト追加のみのため、実行時挙動に変更はありません。
- 将来のリグレッション検知:
removeが「引数なしで呼んだときに別オブジェクトを返す」という仕様remove!が「引数なしで no-op + self を返す」という仕様truncate_wordsが「0 もしくは負のlengthで no-op」という仕様
これらが、将来の refactor などで壊れた際に CI で検知できるようになります。
- 利用側での前提にできること:
truncate_wordsに 0 や負数が渡っても例外にはならず、そのまま文字列が返る前提でロジックを書いてよい(ただし仕様依存になるので、異常値をアプリ側で弾きたいかどうかは別途設計判断)。removeを引数なしで呼んでも安全(no-op)で、かつ破壊はされない。remove!を引数なしで呼んでも安全(no-op)で、オブジェクトは同一。
- 参考情報 (あれば)
- 対象メソッド実装:
active_support/core_ext/string/filters.rb
- 類似 API:
String#delete/String#delete!(Ruby 標準)とのインターフェイス類似性・破壊/非破壊の挙動差分を確認する際に、今回のテストの期待値が参考になります。
- この PR(#57562)は、テストカバレッジ向上が目的であり、仕様追加・変更ではなく「仕様のドキュメンテーションとしてのテスト」の意味合いが強いです。
#57571 Re-raise suppressed RedisClient errors in RedisCacheStore behavior tests
マージ日: 2026/6/4 | 作成者: @yahonda
- 概要 (1-2文で)
RedisCacheStoreCommonBehaviorTestにおいて、Redis 接続のタイムアウトなどでRedisClient::ConnectionErrorが発生した場合に、failsafeによって握りつぶされていたエラーをテスト専用のerror_handlerで再スローするようにし、テスト失敗時に原因が分かりやすくなるようにした PR です。
本番コードの挙動は変えず、遅い CI 環境での「原因不明の nil 返却によるテスト失敗」を「RedisClient のタイムアウトなどが見える失敗」に置き換えるためのテスト改善です。
- 変更内容の詳細
背景
RedisCacheStoreCommonBehaviorTestでは以下のような条件でテストが実行されている:pool: falsetimeout: 0.1秒
- Ruby trunk の debug ビルドなど、非常に遅い環境では
- Redis への connect / read が 0.1 秒を超えることがある
- その結果
RedisClient::ConnectionError(具体的にはReadTimeoutErrorやCannotConnectError)が発生
- しかし
RedisCacheStore#failsafeは、こうしたRedisClientのエラーを rescue してnilを返す仕様になっているため、- たとえば
@cache.write(...)がnilを返し、テストアサーションが「truthy であること」を期待しているとExpected nil to be truthy.という、原因が分かりづらい失敗になる
- たとえば
- テスト環境では suppressed されたエラーがログにも出ないため、「0.1 秒の timeout が原因」というヒントが出ないのが問題だった。
どのような修正か
変更ファイル: activesupport/test/cache/stores/redis_cache_store_test.rb (+4行)
RedisCacheStoreCommonBehaviorTest 内で lookup_store をオーバーライドし、RedisCacheStore 生成時に error_handler オプションを付与するようにしています。この error_handler は、通常 failsafe によって握りつぶされるはずの RedisClient 関連エラーを再スローします。
イメージとしては以下のような変更です(実際のコードから概念的に再構成):
class RedisCacheStoreCommonBehaviorTest < ActiveSupport::TestCase
private
def lookup_store(*args)
super(*args, error_handler: ->(method:, returning:, exception:, **) do
# failsafe が rescue したエラーをここで再スローする
raise exception
end)
end
endポイント:
error_handlerはRedisCacheStoreに元々用意されているフックで、failsafe内部で「エラーが発生した時にどう扱うか」を差し込める仕組み。- 通常は
- 本番などでは「ログに出して nil で握りつぶす」などの使い方をする
- この PR ではテストクラスに限定して
- 「
failsafeが握りつぶそうとしているエラーを、そのまま再スローする」ハンドラを設定
- 「
- これにより、例えば
@cache.write('key', 'value')が timeout により失敗した場合、- 以前:
nilが返ってExpected nil to be truthyとなるだけ - 以後:
RedisClient::ReadTimeoutErrorとしてテストが Error で落ちる
- 以前:
変更前後の出力の違い
変更前:
textFailure: ActiveSupport::Cache::RedisCacheStoreTests::RedisCacheStoreCommonBehaviorTest#test_retains_encoding: Expected nil to be truthy.変更後:
textError: ActiveSupport::Cache::RedisCacheStoreTests::RedisCacheStoreCommonBehaviorTest#test_retains_encoding: RedisClient::ReadTimeoutError: user specified timeout for redis:6379 (redis://redis:6379/1)
これにより、どのテストで、どの Redis 接続に対して、どの種類のエラー(例: ReadTimeoutError)が起きたかが明示されます。
なぜ RedisCacheStoreCommonBehaviorTest のみにスコープしているか
FailureSafety/FailureRaisingといったテスト群は、わざと到達不能な Redis を向くようにして- 「失敗時にどう振る舞うか」をテストしている
- そこに「エラーを必ず再スローする
error_handler」を差し込んでしまうと- 本来の挙動テストの意味が失われる
- 期待している「失敗時に nil を返す」「ログに残す」などが確認できなくなる
- そのため、本 PR の変更は
RedisCacheStoreCommonBehaviorTestクラスのlookup_storeのみに限定されており、他のテストには影響しないようになっている。
- 影響範囲・注意点
- 影響範囲は テストコードのみ:
- 変更ファイルは
activesupport/test/cache/stores/redis_cache_store_test.rb1 ファイル - 行数変更も +4 行のみで、本番環境の
RedisCacheStore実装には手を加えていない
- 変更ファイルは
- 通常の(十分高速な)環境では、タイムアウトや接続エラーが発生しない限り
error_handlerは呼ばれないため、- 既存のテストが通っている状況では挙動は変わらない
- 目的は「不安定な CI や遅いビルド環境で、失敗の原因を可視化すること」であり、
- 不安定さを解消するためのリトライや timeout 値の調整ではなく、
- あくまで「原因を明示する」ための改善である点に注意
- エラーが再スローされることで、これまで
FailureとしてカウントされていたケースがErrorになる可能性はあるが、- そもそも本質的には Redis タイムアウトによるエラーなので、より正しい状態に近づいたといえる
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/57571
- 影響するテストクラス:
ActiveSupport::Cache::RedisCacheStoreTests::RedisCacheStoreCommonBehaviorTest
- 関連クラス / 機能:
ActiveSupport::Cache::RedisCacheStoreRedisCacheStore#failsafeRedisClient::ConnectionError(ReadTimeoutError,CannotConnectErrorなど)error_handlerオプション(RedisCacheStoreのエラー処理フック)
#57549 Test Type::Boolean#serialize and #serialize_cast_value
マージ日: 2026/6/4 | 作成者: @hammadxcm
- 概要 (1-2文で)
このPRは、ActiveModel::Type::Booleanの#serializeおよび#serialize_cast_valueに対するテストを追加し、既存の挙動(#castと同等の振る舞い)を明示的に担保するものです。プロダクションコードの変更は一切なく、テストコードのみが追加されています。
- 変更内容の詳細
対象クラス
ActiveModel::Type::Boolean
これまでは #cast のみがテストされており、以下のメソッドは暗黙的にしかカバーされていませんでした。
#serialize#serialize_cast_value
このPRでは、これら2つのメソッドに対して、実際の仕様どおりの動作が行われていることを確認するテストが追加されています。
#serialize の仕様確認
PR説明文から、#serialize の期待される挙動は次のとおりです。
- 「偽」とみなされる値 →
falseにシリアライズ - 空文字列/
nil→nilにシリアライズ - 上記以外の値 →
trueにシリアライズ
ここでの「偽」とみなされる値は、#cast と同じルールで判定されます。代表的には次のようなものです。
type = ActiveModel::Type::Boolean.new
type.serialize(false) # => false
type.serialize("false") # => false
type.serialize(0) # => false(false 相当とみなされる値)
type.serialize(nil) # => nil
type.serialize("") # => nil
type.serialize(true) # => true
type.serialize("1") # => true
type.serialize("abc") # => true
type.serialize(42) # => true今回のテストでは、#cast と同等のマッピングを行っていることが確認されます。つまり、「DBなどに書き出す直前の値も、キャスト時と同じように true/false/nil に正規化される」という仕様をテストで固定しています。
#serialize_cast_value の仕様確認
#serialize_cast_value は、すでに #cast 済みの値を受け取る想定のメソッドです。
PR説明文によると、その挙動は「渡された値をそのまま返す(パススルー)」です。
type = ActiveModel::Type::Boolean.new
casted_true = type.cast("1") # => true
casted_false = type.cast("0") # => false
casted_nil = type.cast(nil) # => nil
type.serialize_cast_value(casted_true) # => true
type.serialize_cast_value(casted_false) # => false
type.serialize_cast_value(casted_nil) # => nil今回のテストでは、この「変換を行わずに値をそのまま返していること」を保証します。
追加されたテストファイルの内容
変更は以下の1ファイルのみです。
activemodel/test/cases/type/boolean_test.rb(+17/-0)
中身としてはおおむね次のような構成が想定されます(擬似コード):
class ActiveModel::Type::BooleanTest < ActiveModel::TestCase
def setup
@type = ActiveModel::Type::Boolean.new
end
test "serialize mirrors cast semantics" do
assert_equal false, @type.serialize("false")
assert_nil @type.serialize(nil)
assert_nil @type.serialize("")
assert_equal true, @type.serialize("something")
# ...など、false/nil/true 各パターンの確認
end
test "serialize_cast_value is pass-through" do
assert_same true, @type.serialize_cast_value(true)
assert_same false, @type.serialize_cast_value(false)
assert_nil @type.serialize_cast_value(nil)
end
end実際にはもう少し具体的な値がテストされているはずですが、意図としてはこのレベルです。
- 影響範囲・注意点
影響範囲
- コード変更はテストファイルのみであり、ランタイムの挙動は変わりません。
- 既存アプリケーションに対する互換性への影響はありません。
- CIでのテストカバレッジが向上し、
Boolean型シリアライズ周りのリグレッション検知能力が上がります。
注意点 / 読み取り方
#serializeと#castが事実上同じルールで true/false/nil を決定する前提が、テストによって固定化されました。
今後この仕様を変えようとする場合には、テスト修正が必要になります。#serialize_cast_valueが「必ずパススルーである」という仕様も固定されるため、ここで追加の正規化や変換を行う設計は、今後行いにくくなります(行う場合はテストとの矛盾を明示的に解消する必要があります)。- 逆に言えば、「DBに書き込むタイミングで
Booleanが意図せず再解釈される」ということはなく、castされた値がそのまま保存されることがテストで保証された、と捉えることもできます。
- 参考情報 (あれば)
- Rails ガイド(英語): Active Model Basics – Type Cast
ActiveModel::Type系の仕組み・キャストの流れがまとまっています。 - 実際の
ActiveModel::Type::Boolean実装:activemodel/lib/active_model/type/boolean.rb
(このPRでは変更されていませんが、#cast,#serialize,#serialize_cast_valueの実装を確認する際の参照になります)
#57565 Preserve index attributes when renaming an index without native support
マージ日: 2026/6/3 | 作成者: @55728
- 概要 (1-2文で)
rename_indexのフォールバック実装(ネイティブな index rename を持たないアダプタ用)で、partial index のWHEREや列ソート順などの属性が失われていた問題を修正し、元の index 属性を保ったままリネームされるようにした PR です。主に SQLite や古い MySQL/MariaDB で、開発・テスト時の index 挙動が意図せず変わる問題を解消します。
- 変更内容の詳細(あればサンプルコードも含めて)
何が問題だったか
ネイティブな ALTER INDEX … RENAME を持たないアダプタでは、rename_index は以下のようなフォールバック実装になっていました(概念的に):
def rename_index(table, old_name, new_name)
old_index = indexes(table).find { |i| i.name == old_name }
remove_index(table, name: old_name)
add_index(table, old_index.columns, name: new_name, unique: old_index.unique?)
endこのとき add_index に渡しているのは columns と unique だけなので、元の index が持っていた以下のような属性が すべて失われる という問題がありました。
where(partial index の WHERE 句)orders(列ごとの ASC/DESC)lengths(プレフィックス長など)opclassesusing(USING btree 等)typeinclude(covering index の INCLUDE 列)nulls_not_distinctcomment(MySQL/MariaDB の index コメント)
問題例:
add_index :posts, :views,
where: "title IS NOT NULL",
name: "idx",
order: { views: :desc }
rename_index :posts, "idx", "idx2"
# 修正前:
# - WHERE 句が消える
# - order 情報も消える
# - 結果として単なる (views) の index になる特に SQLite では rename_column 実装の一部として index の付け替えに rename_index が使われるため、開発・テスト環境の SQLite で:
- unique な partial index の「どの行が一意制約の対象か」が変わってしまう
- 本番(PostgreSQL など)とは違う挙動になる
という、かなり危険なズレが起きうる状態でした。
今回の修正内容
フォールバックパス(super による実装)で index を作り直す際に、IndexDefinition が持っている各種属性を add_index に引き継ぐようにしました。
具体的には以下の属性を、nil / 空でなければ渡します:
whereorderslengthsopclassesusingtypeincludenulls_not_distinctcomment(SQLite では無意味だが、MySQL/MariaDB のフォールバック向けに保持)
イメージ:
def rename_index(table_name, old_name, new_name)
index = indexes(table_name).find { |i| i.name == old_name }
options = {
name: new_name,
unique: index.unique
}
options[:where] = index.where if index.where
options[:order] = index.orders if index.orders.present?
options[:length] = index.lengths if index.lengths.present?
options[:opclass] = index.opclasses if index.opclasses.present?
options[:using] = index.using if index.using
options[:type] = index.type if index.type
options[:include] = index.include if index.include.present?
options[:nulls_not_distinct] = index.nulls_not_distinct if index.nulls_not_distinct
options[:comment] = index.comment if index.comment
remove_index(table_name, name: old_name)
add_index(table_name, index.columns, **options)
end(実際のコードは既存の抽象実装に最小限の変更を加える形ですが、ロジックとしては上記のように「元 index の属性をすべて合成して渡す」方針になっています。)
テスト
以下のテストが追加されています:
test_rename_index_preserves_where_clausesupports_partial_index?を満たすアダプタでのみ実行- rename 前後の
where属性を比較し、保持されていることを確認
test_rename_index_preserves_ordersupports_index_sort_order?を満たすアダプタでのみ実行- rename 前後の
orders属性({ column_name => :asc/:desc })を比較
SQLite3 では修正前に where が nil、orders が {} に変わることを確認し、修正後は preserved になることを確認済み。PostgreSQL ではもともとネイティブ rename (ALTER INDEX ... RENAME) を使っているため挙動に変更はなく、テストもグリーンのままです。
CHANGELOG にも「rename_index が index 属性を保持するようになった」旨が追記されています。
- 影響範囲・注意点
影響を受けるアダプタ
| Adapter | rename_index の実装 | 影響 |
|---|---|---|
| PostgreSQL | ALTER INDEX … RENAME(ネイティブ) | もともと問題なし(変化なし) |
| MySQL ≥ 5.7.6 / MariaDB ≥ 10.5.2 | ALTER TABLE … RENAME INDEX(ネイティブ) | もともと問題なし(変化なし) |
| SQLite | フォールバック (super) | 今回の修正で挙動が改善 |
| MySQL < 5.7.6 / MariaDB < 10.5.2 | フォールバック (super) | 今回の修正で挙動が改善 |
特に現代的な開発環境で多いパターン:
- 開発・テスト: SQLite
- 本番: PostgreSQL / MySQL
この構成で、SQLite 上の rename_column / rename_index 実行後も、Partial Index やソート順などが本番とずれない 方向に直っています。
どんなケースで効いてくるか
SQLite で以下のようなマイグレーションをしている場合:
rubyadd_index :posts, :views, where: "title IS NOT NULL", unique: true, name: "idx_posts_on_views_partial" rename_index :posts, "idx_posts_on_views_partial", "idx_posts_on_views_uniq"修正前:
idx_posts_on_views_uniqはwhereが無視され、全行に対して unique 制約がかかる
修正後:where: "title IS NOT NULL"を保ったまま rename されるrename_columnが内部で index の張り替えを行う際にrename_indexが使われるアダプタ(SQLite など)の場合、
修正前は「カラム名変更に伴い暗黙に index 属性が失われる」ことがあったが、修正後は属性が維持される
注意点
- 仕様としては「正しい動作」に近づいている変更であり、後方互換性破壊というより、今まで silently バグっていた挙動の修正 と捉えるのが適切です。
- ただし、もし「partial index の WHERE が消える前提」でワークアラウンドしていたようなコードがある場合は、その前提が崩れます(通常はそのような前提はバグ寄りのため、修正は歓迎されるはずです)。
- SQLite は index コメントをサポートしませんが、MySQL/MariaDB のフォールバックパスに備えて
commentもコピーされます。実際にコメントが保存されるか・どう表現されるかはアダプタ依存です。
- 参考情報 (あれば)
- 対象 PR:
rails/rails#57565 「Preserve index attributes when renaming an index without native support」 - 関連 Issue: #16619(2014 年の報告。「抽象実装は where をサポートしないアダプタ向け」という理由で当時はクローズされていたが、現在は SQLite などが partial index をサポートするため前提が変わっている)
- 対応ファイル:
activerecord/CHANGELOG.mdactiverecord/lib/active_record/connection_adapters/abstract/schema_statements.rbactiverecord/test/cases/migration/index_test.rb
#57563 Fix reset_column_sequences! for a table in a quoted schema
マージ日: 2026/6/3 | 作成者: @55728
- 概要 (1-2文で)
PostgreSQL でスキーマ名をクォートしたテーブル(例:"App".widgets)に対してreset_column_sequences!がNoMethodErrorで落ちていた不具合を修正した PR です。これにより、quoted schema を使う環境でもreset_column_sequences!/reset_pk_sequence!/ fixture ロードが正常に動作するようになります。
- 変更内容の詳細
不具合の症状
例えば以下のように CamelCase なスキーマをクォートして作り、そのテーブルに対して reset_column_sequences! を呼び出すと例外が発生していました。
CREATE SCHEMA "App";
CREATE TABLE "App".widgets (id serial primary key);connection.reset_column_sequences!([['"App".widgets']])
# => NoMethodError: undefined method 'column=' for nilreset_pk_sequence! や fixture ロードも内部で reset_column_sequences! を呼ぶため同様にクラッシュします。
根本原因
SequenceReset(reset_column_sequences! の内部実装)がテーブルを管理するために、以下の2種類のテーブル名を扱っていました。
- 呼び出し側から渡されるテーブル名(map のキー)
- PostgreSQL カタログから取得する
regclass::textの文字列(schema-qualified かつ必要に応じてクォートされた名前)
regclass::text は例えば以下のような文字列になります。
"App".widgetspublic.widgets
元のコードでは、regclass::text からスキーマ名を外したりクォートを剥がしたりするために、
delete_prefix('"').delete_suffix('"')といった「外側のダブルクォートだけを削る」処理を使っていました。そのため:
"App".widgetsdelete_prefix('"')→App".widgetsdelete_suffix('"')→ 末尾は"ではないのでそのまま- 結果:
App".widgetsという壊れた文字列になる
この壊れた文字列で @tables のハッシュを引こうとするため、キーが見つからず nil が返り、nil.column = ... で NoMethodError が発生していました。
同じ問題が「主キー用のシーケンス」と「UUID 用のシーケンス」の両方のループで起きていました。既存のテストは小文字スキーマなど quoted schema のケースをカバーしていなかったため、今まで検知されていませんでした。
修正内容
テーブル名のパースと正規化に ActiveRecord::ConnectionAdapters::PostgreSQL::Utils.extract_schema_qualified_name を使うように変更し、以下を「常に同じロジック」で処理するように統一しました。
@tablesハッシュのキーにする名前regclass::textから逆引きする際に使う名前
具体的には、壊れた独自の「クォート剥がし」ロジック(delete_prefix / delete_suffix)を削除し、代わりに
Utils.extract_schema_qualified_name(...).to_sで得られた名前を使うようにしています。
extract_schema_qualified_name は、スキーマ付き・クォート付きのテーブル名を安全にパースして、内部的に「スキーマ」と「テーブル」の構造を理解した上で正規化した文字列を返すユーティリティです。これにより:
"App".widgetsのような CamelCase スキーマpublic.widgetsのような unquoted / lowercase の一般的なケース"weird-Name"."weird.Table"といった複雑なクォートケース
のいずれでも整合性のあるキー生成と逆引きが行われます。
補足として、他の unquote_identifier 利用箇所も監査されましたが、
- そこでは「カラム名など単一の identifier」を対象にしている
- もしくは既に
extract_schema_qualified_nameを使用している
ため、今回のようなバグは起きないことが確認されています。
テスト
新たに以下のテストが追加されています。
test_reset_column_sequences_with_quoted_schemaテスト内容:
"Test_CamelSchema"という CamelCase の quoted schema にテーブルを作成id = 100の行を手動挿入reset_column_sequences!を呼び出し- 次に挿入されるレコードの
idが101になることを確認
このテストは:
- 現在の
mainではNoMethodErrorが発生して red になる - 本 PR の修正後は
101にシーケンスが進んで green になる
ことが、実際の PostgreSQL 上で確認されています。
- 影響範囲・注意点
- 影響を受けるケース:
- PostgreSQL を使用していて
- CamelCase や大文字を含むスキーマ名を
"SchemaName"のようにクォートして定義し - そのスキーマ内のテーブルに対して
reset_column_sequences!/reset_pk_sequence!/ fixtures ロードを実行している場合
- この修正により、これらのケースで発生していた
NoMethodErrorが解消され、シーケンスが正しく「既存データの最大 ID + 1」にリセットされます。 - lowercase かつ unquoted なスキーマ・テーブル名のみを使っている一般的なアプリケーションでは、動作の変更はほぼありません(内部実装がより堅牢になっただけ)。
- スキーマ名・テーブル名・カラム名などにクォートが絡む環境で、シーケンスのリセット処理がより安全になります。独自タスクや Rake タスクで
reset_column_sequences!を利用している場合も恩恵があります。
- 参考情報 (あれば)
- 関連 PR:
- #57561:
foreign_keyscorruptingto_tablein a quoted schema
同じ「quoted schema 名の扱い」に起因する別箇所のバグ修正で、foreign_keysのto_tableがおかしくなる問題に対応しています。本 PR とは独立にマージ可能。
- #57561:
- 変更ファイル:
activerecord/CHANGELOG.mdactiverecord/lib/active_record/connection_adapters/postgresql/schema_statements.rbactiverecord/test/cases/adapters/postgresql/schema_test.rb
- テスト:
schema_test.rb全体で 82 tests, 0 failures が確認されています。
#57560 Fix nested attributes :limit miscounting a single-record hash
マージ日: 2026/6/3 | 作成者: @55728
- 概要 (1-2文で)
accepts_nested_attributes_for ..., limit: N使用時に、1件分のレコードを表すidキー付きハッシュが「N件を超えた」と誤判定されてTooManyRecordsが発生するバグが修正されました。レコード数ではなくハッシュのキー数を数えていた実装を、正しく「レコード数」を数えるように変更しています。
- 変更内容の詳細
バグの内容
accepts_nested_attributes_for に limit オプションを指定しているとき、以下のような「1件の関連レコードを更新するためのハッシュ」を渡すとエラーになっていました。
class Pirate < ApplicationRecord
has_many :parrots
accepts_nested_attributes_for :parrots, limit: 2
end
pirate.parrots_attributes = {
"id" => 1,
"name" => "Polly",
"color" => "green",
"breed" => 1
}
# 期待: 単一レコード更新として成功する
# 実際: TooManyRecords: Maximum 2 records are allowed. Got 4 records instead.原因は、**「レコード数のチェックが、正規化(ハッシュ → 配列化)より前に実行されていた」**ことです。
Rails は nested attributes で以下のような2パターンを受け取ります:
「複数レコード」形式(ハッシュ of ハッシュ or 配列)
ruby{ "0" => { "name" => "Polly" }, "1" => { "name" => "Cracker" } } # => Hash#size == 2 (レコード数と一致)「単一レコード」形式(
idを含むフラットなハッシュ)ruby{ "id" => 1, "name" => "Polly", "color" => "green", "breed" => 1 } # => Hash#size == 4 (これは「属性数」であってレコード数ではない)
従来の実装では check_record_limit! がこの正規化より前で Hash#size を見ていたため、
ケース2では「1レコード」ではなく「4レコード」と誤カウントされていました。
修正内容
activerecord/lib/active_record/nested_attributes.rb において:
check_record_limit!の実行位置を、入力パラメータの「ハッシュ → 配列」正規化後に移動idキー付き単一ハッシュは内部的に[attributes_hash]のような1要素配列に包まれてからチェックされる- この結果、単一レコードの場合は常に「1件」として数えられる
これにより、以下の動作になります:
pirate.parrots_attributes = {
"id" => 1,
"name" => "Polly",
"color" => "green",
"breed" => 1
}
# => 正常に1件の更新として処理される(limit: 2 に抵触しない)一方で、以下のような genuine な「レコード数オーバー」ケースは従来通りエラーになります:
pirate.parrots_attributes = [
{ "name" => "Polly" },
{ "name" => "Cracker" },
{ "name" => "Kiwi" }
]
# limit: 2 の場合 => TooManyRecords が発生(期待通り)テスト
activerecord/test/cases/nested_attributes_test.rb に以下のテストが追加されています:
test_limit_does_not_count_the_attributes_of_a_single_record_hash
このテストは共有モジュール NestedAttributesLimitTests に追加されており、
limit: 2(数値)limit: :some_symbollimit: -> { ... }(Proc)
といった3種の limit 設定すべてで同じケースが検証される構成です。
テスト内容:
limit: 2の関連に、4つのキーを持つid付きハッシュで1レコード更新を行うTooManyRecordsが発生しないこと- レコードが期待通り更新されていること
nested_attributes_test.rb 全体(170テスト)でグリーンであることが確認されています。
activerecord/CHANGELOG.md にもこの修正内容が追記されています。
- 影響範囲・注意点
- 対象:
accepts_nested_attributes_forにlimit:を指定しているすべてのコード- 特に、「既存レコードの更新」を
idキー付きフラットハッシュで行っているケースに影響
- 特に、「既存レコードの更新」を
- 期待される挙動:
- これまで 誤って
TooManyRecordsが出ていたケース(単一レコード更新)が正常に通るようになります - 実際にレコード数が
limitを超えている入力(配列 or hash-of-hashes)は、従来通りTooManyRecordsが発生します
- これまで 誤って
- 互換性:
- レコード数カウントのタイミングが変わっただけで、ビジネスロジック上の制約(何件まで許可するか)は変わっていません
- 「これまでバグを前提にしていた」ようなコード(例: 単一レコード更新をあえて弾かせていた…など)があれば挙動が変わりますが、通常は望ましい修正と考えてよいです
確認したほうがよいポイント:
- 管理画面や API で nested attributes を使って既存子レコードを更新している箇所があり、
limit:を設定しているid: ...を含む単一ハッシュでリクエストしている
これらが以前から「なぜか TooManyRecords になる」などの問題を抱えていた場合、この修正で解消されている可能性が高いです。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57560
- 該当コード:
ActiveRecord::NestedAttributes(activerecord/lib/active_record/nested_attributes.rb) - 概念整理:
- nested attributes の入力形式
- 単一レコード更新:
{"id" => 1, "attr1" => "x", ...} - 複数レコード:
[{...}, {...}]または{"0" => {...}, "1" => {...}}
- 単一レコード更新:
limitは「レコード数の上限」であり、「属性数の上限」ではない
→ 今回の修正はこの意図に沿ったカウント方法に是正したものです。
- nested attributes の入力形式
#57550 Make Ractor shareability methods only available on 4.0 and above.
マージ日: 2026/6/3 | 作成者: @andrewn617
- 概要 (1-2文で)
Rails の Ractor 用ヘルパーメソッド(ractor_make_shareable/ractor_shareable_procなど)を、「Ruby 4.0 以上では本物を提供し、Ruby 3.x では一括で no-op(何もしない)にする」という方針に整理した PR です。
これにより Ruby 3.x でRactor.make_shareableだけが中途半端に働いて例外が出る、といった不整合を避けます。
- 変更内容の詳細
※実際の diff は activesupport/lib/active_support/ractors.rb のみ(+14 / -20 行)で、主に条件分岐ロジックの修正です。
背景となる問題
Rails では Ractor 対応のため、以下のようなヘルパーを ActiveSupport に用意しようとしていました:
ractor_make_shareable(obj):Ractor.make_shareable(obj)相当のラッパーractor_shareable_proc(&block): Ractor で共有可能なProcを生成するヘルパー
ところが Ruby 3.x 系では:
Ractor.make_shareableは存在する- しかし
Ractor.shareable_procは存在しない(Ruby 4.0 で導入予定)
そのため:
- Rails 側で
make_shareableについては shim(互換関数)を用意 shareable_procは Ruby 3.x では意味のある実装ができないので no-op にする
という構成にすると、次のようなコードが Ruby 3.4 では例外を起こす、という問題があります。
# Ruby 3.x 環境でのイメージ
proc = ractor_shareable_proc { foo } # 実際には「共有可能」になっていない no-op
ractor_make_shareable(Bar.new(proc)) # => Bar 内に「共有不可能な proc」が含まれているので
# Ruby 3.4 の Ractor.make_shareable 呼び出しで例外一方で、Ruby 4.0 では Ractor.shareable_proc が存在するため、同じコードが正常に動く、というギャップが生じます。
この PR の方針
PR では、Rails での Ractor サポートは Ruby 4.0 を前提とし、Ruby 3.x では「Ractor 関連のヘルパーはすべて no-op に寄せる」方向へ整理しています。
実装イメージとしては:
# 疑似コード: バージョンは例
if RUBY_VERSION >= "4.0.0"
# Ruby 4.0 以上: Ractor をきちんと使う
def ractor_make_shareable(obj)
Ractor.make_shareable(obj)
end
def ractor_shareable_proc(&block)
Ractor.shareable_proc(&block)
end
else
# Ruby 3.x: Ractor サポートは「存在するが何もしない」扱い
def ractor_make_shareable(obj)
obj # そのまま返す (no-op)
end
def ractor_shareable_proc(&block)
block # そのまま返す、実際には shareable にはならない
end
endポイントは:
- Ruby 4.0 以上
- Rails は Ruby が提供する本物の
Ractor.make_shareable,Ractor.shareable_procを利用 - Ractor ベースで本番運用できることを目標とした設計
- Rails は Ruby が提供する本物の
- Ruby 3.x
- すべての Ractor ヘルパーを no-op にする
- 「
make_shareableだけ実体があってshareable_procは no-op」という中途半端な状態を避ける - その結果、3.x では「Ractor をちゃんと使える」ことは保証しない
これによって、Ruby 3.x で
ractor_shareable_procが共有不可能な Proc を返す- それを含んだオブジェクトに対して
ractor_make_shareable(実体は Ruby のRactor.make_shareable) を呼ぶ
という「内部的に矛盾した状態」がそもそも発生しないようにしています。
- 影響範囲・注意点
影響範囲
- 対象:
ActiveSupportの Ractor 関連ヘルパーを利用しているコードActiveSupport::Ractors(またはそれを内部で利用するコンポーネント)
- 実行環境: Ruby 3.x / Ruby 4.0 以上で挙動が分岐
Ruby 4.0 以上での挙動
- Ractor 関連ヘルパーは Ruby 標準の機能に紐づいて、実際に Ractor セーフなオブジェクトや
Procを生成します。 - Ractor を本格的に利用した並行処理(マルチ Ractor)を Rails 上で実用的に構成できる前提になります。
Ruby 3.x での挙動と注意点
- Ractor ヘルパーは 存在するがすべて no-op(何もしない) になる想定です。
- 例外は極力発生しないようにしてあるが、その代わり「Ractor 対応」にはなっていません。
- 3.x 上で Ractor を前提とした並列処理を Rails 内部やアプリケーションコードで行うのは推奨されず、将来的に Ruby 4.0 をターゲットにすべき、というメッセージと言えます。
- Rails 側はこの挙動を前提に実装していくため、
- Ruby 3.x で「Ractor をちゃんと使えること」を期待したテストや本番運用は壊れる可能性があります。
- Ractor 前提のコードを書く場合は、Ruby 4.0 をターゲットに準備する必要があります。
移行上のポイント
- すでに独自に
Ractor.make_shareableを直接呼び出しているコードには、この PR は直接影響しません(Rails のヘルパー周りの整理なので)。 - ただし、今後 Rails 側の設計として「Ractor 対応 = Ruby 4.0 前提」が強まるため:
- 新規に Ractor を利用する並列処理を書きたい場合
- Ruby 4.0 を最低ターゲットとして検討する
- Rails の Ractor ヘルパーを使う場合は「3.x では no-op」と割り切る
- ライブラリ作者は、Ractor 対応をうたう際は「Ruby 4.0 以上でテスト済み」であることを明確にしておくとよいです。
- 新規に Ractor を利用する並列処理を書きたい場合
- 参考情報 (あれば)
- この PR がフォローアップしている PR:
- Ruby 本体側の Ractor 関連メソッド:
Ractor.make_shareable(obj)- オブジェクトを Ractor 間で共有可能にする。共有不可能な要素を含むと例外が発生。
Ractor.shareable_proc(&block)(Ruby 4.0 以降で導入予定)- Ractor 間で共有可能な
Procを生成する、Proc用の特別なコンストラクタ的メソッド。
- Ractor 間で共有可能な
#57567 Read mysql2 affected_rows during perform_query
マージ日: 2026/6/3 | 作成者: @matthewd
- 概要 (1-2文で)
MySQL 用の ActiveRecord アダプタで、クエリ実行時にaffected_rows(影響を受けた行数)を確実に取得できるようにする変更です。mysql2がnilを返すケースでも、ActiveRecord::Resultを使って影響行数を運べるようにし、テストもそれを検証しやすい形に調整しています。
- 変更内容の詳細
背景
mysql2のクエリ実行結果は、- 行とカラムがある「通常の SELECT 結果」 →
Mysql2::Result UPDATE/DELETEなどで行数のみが意味を持つ結果 →nil(結果セットなし)
という形になることがあります。
- 行とカラムがある「通常の SELECT 結果」 →
- ActiveRecord 側では、
affected_rows(更新・削除された行数など)をテストしているが、perform_queryのタイミングで正しく読み取れておらず、ローカルの AR テストで半分くらいの確率で落ちる状況があったとのことです。
生の結果型を変更
これまで:
- Mysql2Adapter の「raw な結果」の型は
Mysql2::Resultかnilのどちらかだった。 mysql2が結果セットを返さない(nilを返す)ケースでは、ActiveRecord 内で影響行数を運ぶためのオブジェクトがなく、「影響行数だけを持っている結果」を表現しづらかった。
これから:
- 「raw 結果」の型を
Mysql2::Result(行とカラムを持つ結果)ActiveRecord::Result(影響行数などを表現できる AR の結果オブジェクト)
のどちらかに変更。
- 行とカラムを持つ「フルな」結果については、今まで通り
Mysql2::Resultを返し、その後の段階でActiveRecord::Resultに変換するフローは変更しない。 - 一方、
mysql2がnilを返すケース(典型的にはaffected_rowsだけ意味がある結果)では、その場でActiveRecord::Resultを生成し、そこにaffected_rowsを保持させるようにする。
PR 説明のポイントを言い換えると:
- 生の結果型を
Mysql2::Result | nil→Mysql2::Result | ActiveRecord::Resultに変更nilで済ませていた「影響行数だけある結果」を、ActiveRecord::Resultに載せて扱う- すでに Sqlite3Adapter は同様に
ActiveRecord::Resultを “raw” として再利用しており、その方針に合わせている
perform_query 内で affected_rows を読むタイミングを調整
PR タイトルにもある通り、「perform_query 中に mysql2 の affected_rows を読む」ようにしています。
これにより、MySQL クライアントの状態が変わる前に確実に行数が取得され、後段の処理で値が取り違えられたり、0 に見えてしまうといった不具合を防ぎます。
(PR 本文からは細かいメソッド呼び出しの変更までは読み取れませんが、database_statements.rb の差分 (+21/-17) で、perform_query or その周辺で affected_rows を読んで ActiveRecord::Result に詰める処理を挿入・整理していると考えられます。)
テストの変更
対象テスト:activerecord/test/cases/adapters/abstract_mysql_adapter/count_deleted_rows_with_lock_test.rb
- 「
LOCKを絡めた削除クエリで削除行数をきちんとカウントできるか」をテストしているファイル。 - ローカル環境で「半分くらいの頻度で落ちる」不安定テストだったため、
- 失敗を再現しやすいようにテスト内容を少し強める/明確にする
- あるいは、今回の
affected_rowsの扱い変更に合わせて期待値の取り方を揃える
といった形で +6/-2 行の修正が入っています。
- 説明文には「将来のリグレッション時に失敗が起きやすくなることを期待してテストを書き換えた」とあるので、
- 以前はバグがあってもテストが通ってしまうケースがあった
→ 仕様どおりでなければ落ちるようにテストを強化した
と理解しておくと良いです。
- 以前はバグがあってもテストが通ってしまうケースがあった
- 影響範囲・注意点
- 対象:
mysql2アダプタ (Mysql2Adapter) を使っている MySQL / MariaDB 環境の Rails アプリケーション。 - 影響する処理:
UPDATE/DELETE/INSERTなど、結果セットを返さずaffected_rowsだけ意味を持つクエリ。- 特に
LOCKを絡めた削除(テスト名からするとDELETE ... LOCK IN SHARE MODE的なもの)での削除行数カウント。
- 互換性:
- 「通常の SELECT 結果」は引き続き
Mysql2::Resultベースで扱われるため、既存コードがMysql2::Resultを前提にしていても挙動に変化はないはずです。 - 結果セットが無いケースで、内部的に
nilではなくActiveRecord::Resultが流れるようになるため、アダプタ内部やプライベート API に依存しているコード(独自 monkey patch やメトリクス用途のラッパなど)があれば、nil前提ロジックが壊れる可能性はあります。
- 「通常の SELECT 結果」は引き続き
- 利点:
affected_rowsを確実に取得できるようになることで、delete_all/update_allの戻り値lock付きクエリ実行時の削除件数のカウント
などの信頼性が向上します。
Sqlite3Adapter同様に、ActiveRecord::Resultを「raw」として再利用する設計に揃えたため、アダプタ間の一貫性が増しています。
- テスト面:
- 影響行数周りのリグレッション(戻りバグ)が将来発生した際には、今回変更されたテストがより高い確率で検知してくれるようになります。
- 参考情報 (あれば)
- 対象 PR: https://github.com/rails/rails/pull/57567
- 関連箇所:
activerecord/lib/active_record/connection_adapters/mysql2/database_statements.rbactiverecord/lib/active_record/connection_adapters/mysql2_adapter.rbactiverecord/test/cases/adapters/abstract_mysql_adapter/count_deleted_rows_with_lock_test.rb
- 設計上の類似点:
Sqlite3AdapterもすでにActiveRecord::Resultを「raw 結果」として再利用しており、MySQL アダプタもそれに寄せた形になっています。
#57561 Fix PostgreSQL foreign_keys for a target table in a quoted schema
マージ日: 2026/6/3 | 作成者: @55728
- 概要 (1-2文で)
PostgreSQL でスキーマ名がクォートされたテーブルに対する外部キーを扱う際、foreign_keysが参照先テーブル名(to_table)を壊してしまい、rails db:schema:dumpから復元できないスキーマが生成されていた問題を修正する PR です。regclass::textの結果に対して不適切なアンクォート処理をしていたのを、スキーマ付き・クォート付きのテーブル名を正しく扱えるパーサに差し替えています。
- 変更内容の詳細
問題の具体例
PostgreSQL で、クォートされたスキーマにテーブルがある場合:
CREATE SCHEMA "App";
CREATE TABLE "App".customers (...);
CREATE TABLE orders (
customer_id bigint REFERENCES "App".customers(id)
);Rails から:
connection.foreign_keys("orders").first.to_table
# 修正前: "App\".customer" (壊れている)
# 期待値: "App.customers"この to_table がスキーマダンプにそのまま使われるため、schema.rb には壊れた add_foreign_key が出力され、rails db:schema:load で失敗する、というバグでした。
根本原因
参照先テーブル名は PostgreSQL の
regclass::textから取得している。regclass::textは「スキーマ付き + 必要部分のみクォートされた識別子」を返す:regclass::text の例 "App".customerspublic."Mixed""Schema"."Table""Mixed"customersこれに対して
Utils.unquote_identifierをそのまま適用していたが、このメソッドは「単一の識別子だけを想定して、先頭と末尾の1文字を剥がす」実装だったため、スキーマ+テーブルのような複合名を壊してしまっていた:入力 ( regclass::text)unquote_identifierの結果正しい期待値 "App".customersApp".customerApp.customerspublic."Mixed"public."Mixed"(変化なし)public.Mixed"Schema"."Table"Schema"."TableSchema.Tableスキーマなし(
"Mixed",customers)のケースだけはたまたま動いていたため、長年見落とされていた。
修正内容
foreign_keys内で参照先テーブル名を扱う箇所を変更:- Before:
Utils.unquote_identifier(raw_name_from_regclass) - After:
Utils.extract_schema_qualified_name(raw_name_from_regclass).to_s
- Before:
extract_schema_qualified_nameは、すでに他の場所で使われているヘルパで、- スキーマ名 + テーブル名
- 必要な部分のみクォートされた識別子
を正しくパースし、schema.tableの形の文字列を返せるようになっている。
結果として:
# 修正後
connection.foreign_keys("orders").first.to_table
# => "App.customers"となり、schema.rb にも正しい add_foreign_key "orders", "App.customers", ... が出力されます
(実際にはアダプタ内部表現に準じた形式ですが、少なくとも壊れた文字列にはならない)。
テストの追加
activerecord/test/cases/migration/foreign_key_test.rb に PostgreSQL 専用のテストを追加:
test_foreign_key_to_table_in_quoted_schema- 混在ケースのスキーマ
"Aerospace"を作成 "Aerospace".boostersテーブルを作り、別テーブルから外部キーを張るconnection.foreign_keys(...).first.to_tableが"Aerospace.boosters"であることを検証
- 混在ケースのスキーマ
その他、既存テスト (foreign_key_test.rb, references_foreign_key_test.rb, schema_test.rb) が PG 17 でグリーンであることも確認済みです。
- 影響範囲・注意点
対象DB: PostgreSQL のみ。他DBアダプタには影響なし。
主な影響箇所:
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#foreign_keys- これを間接的に利用する
rails db:schema:dump/db:structure:dumpなどのスキーマダンプ機能
影響するアプリの特徴:
- PostgreSQL を使用している
- クォートされたスキーマ名 (例:
"App","Aerospace") を利用している - そのスキーマ内のテーブルを参照する外部キーがある
互換性上の懸念点:
- アンバグなケース(スキーマなしテーブル名、普通のスネークケーススキーマ名)は動作に変化なし。
- 参照先テーブル名の解釈ロジックがより厳密かつ正確になっただけなので、正しく設定されている環境では挙動悪化は考えにくい。
- 逆に、これまで「壊れた
schema.rbを前提にしたワークアラウンド」を入れていた場合は、そのワークアラウンドが不要/有害になる可能性があるため、該当プロジェクトでは差分を注意して確認した方がよいです。
既存の壊れた
schema.rbについて:- この PR は「新たにダンプされるスキーマを直す」ものであり、すでに壊れた
schema.rb自体を自動修正するものではありません。 - もし現状の
schema.rbに"App\".customer"のような文字列が含まれている場合は、この修正を含む Rails バージョンに上げた上で、DB から改めてdb:schema:dumpし直すことを推奨します。
- この PR は「新たにダンプされるスキーマを直す」ものであり、すでに壊れた
- 参考情報 (あれば)
- この PR:
- Fix PostgreSQL
foreign_keysfor a target table in a quoted schema (#57561)
- Fix PostgreSQL
- 関連 PR:
reset_column_sequences!がクォートされたスキーマでクラッシュする問題の sibling PR: #57563
(同じ根本原因だが呼び出し元が異なる問題を別途修正)
- 過去の関連議論(クロススキーマFK全般の話であり、このバグそのものではない):
- #16907
- #28654
#57547 Test Mime::Type#=== and nil matching
マージ日: 2026/6/3 | 作成者: @hammadxcm
- 概要 (1-2文で)
このPRは、Mime::Typeに既に存在している挙動(#===の配列マッチと、#=~/#match?のnilガード)に対するテストを追加するだけの変更です。プロダクションコードの変更は一切なく、テストカバレッジを補完する目的のPRです。
- 変更内容の詳細
変更ファイルは 1 つのみです。
actionpack/test/dispatch/mime_type_test.rb(+10/-0)
追加されたテストで確認しているのは主に次の2点です。
(1) Mime::Type#=== が「配列」に対してもマッチすること
Mime::Type#=== は、通常の case ... when での利用を想定しており、
case request.format
when Mime[:html]
...
when Mime[:json]
...
endのように使われますが、「複数の MIME Type をまとめた配列」 に対してもマッチする仕様になっています。
PR説明中の "matches when the type is included in a given array" というのは、例えば次のようなパターンです。
type = Mime[:html]
list = [Mime[:html], Mime[:json]]
type === list # => true (配列に含まれているので true)または case/when での利用イメージだと:
formats = [Mime[:html], Mime[:json]]
case formats
when Mime[:html]
# "html" が formats 配列に含まれているためマッチする
endこの 「配列を渡したときに、その配列内に self (Mime::Type インスタンス) が含まれていれば true を返す」 という挙動に対して、これまでテストが存在していなかったため、専用のテストが追加されています。
(2) Mime::Type#=~ / Mime::Type#match? が nil を安全に扱うこと
Mime::Type#=~ と Mime::Type#match? は、引数が nil のときに例外を出さず、常に false を返すようにガードされています。
例:
html = Mime[:html]
html =~ nil # => false
html.match?(nil) # => false従来からこのガードは存在していましたが、挙動を担保するテストがなかったため、今回のPRで nil を渡した場合に false が返ることを確認するテストが追加されました。
- 影響範囲・注意点
- プロダクションコードの変更は一切なく、テストコードのみの追加です。
- 既存の
Mime::Type#===/#=~/#match?の挙動には変更がありません。 - したがって、アプリケーション側の挙動の変化や後方互換性の問題はありません。
- ただし、今回テストで明示的にカバーされたことで、
Mime::Type#===が配列を受けたときに「自身がその配列に含まれているかどうか」で判定する、#=~/#match?にnilを渡した場合は常にfalseを返す、 という挙動が仕様としてより強く固定化されたと解釈できます。将来この挙動を変える場合はテストの修正が必要になります。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57547
Mime::Typeは主にActionDispatch/ActionPackの一部として、request.formatなどのコンテンツネゴシエーションやレスポンスのフォーマット判定に利用されます。case/whenでMime::Typeを使う際に、複数フォーマットを一度に扱いたい場合や、nilが来る可能性のある入力をマッチングに使う場合の挙動確認として、このPRで追加されたテストが仕様の参考になります。
#57556 Test ParameterFilter#filter with empty filters returns a dup
マージ日: 2026/6/3 | 作成者: @hammadxcm
- 概要 (1-2文で)
ActiveSupport::ParameterFilter#filterに対して、「フィルタが空のときはparams.dupを返す」という振る舞いを確認するテストが追加された PR です。
本番コードの変更はなく、テストコードのみの追加です。
- 変更内容の詳細
何をテストしているか
ActiveSupport::ParameterFilter は、指定したキーをマスクしたりするためのユーティリティですが、フィルタ条件が空配列 / 空リストのときは、実装上「高速経路」として params.dup を返すようになっています。
元々は、クラスメソッド風の filter_param に対しては「空フィルタ時の挙動」がテストされていましたが、インスタンスメソッドの #filter については同じパターンのテストが存在しなかったため、そのカバレッジを追加した PR です。
テストでは以下の2点を確認しています:
filterの返り値の内容が元のparamsと「等しい」rubyassert_equal params, filtered- ただし、同一オブジェクトではなく複製 (
dup) であることrubyrefute_same params, filtered
イメージとしては、下記のようなテストケースが追加されています(概念的なコード例):
def test_filter_with_empty_filters_returns_dup
params = { "foo" => "bar" }
filter = ActiveSupport::ParameterFilter.new([])
filtered = filter.filter(params)
# 内容は同じ
assert_equal params, filtered
# だがオブジェクトは別(dupであることを期待)
refute_same params, filtered
endこのようにして、「空のフィルタでもオブジェクトをそのまま返さず必ず dup している」という既存仕様をテストで明示的に固定しています。
- 影響範囲・注意点
- 本番コードは一切変更されていないため、ランタイムの挙動・パフォーマンスに直接の影響はありません。
- ただしテストが追加されたことで、以下のような将来の変更が仕様としてロックインされたと解釈できます:
- フィルタが空でも
#filterは「同じハッシュオブジェクトをそのまま返さず、必ず複製して返す」こと。
- フィルタが空でも
- そのため、将来
ParameterFilterの実装を最適化して「空フィルタ時はそのまま同一オブジェクトを返す」というような変更をした場合、このテストが落ちるようになります。- つまり、「呼び出し側が
filterの結果を破壊的に変更しても元のparamsに影響しない」前提が仕様として保証される方向に強化されています。
- つまり、「呼び出し側が
ParameterFilter#filterの呼び出し側で、「返ってきたオブジェクトがparamsと同一であること」に依存したコードを書いていた場合(通常はないことが望ましいですが)、テスト上は今後もそれは成立しない前提となります。
- 参考情報 (あれば)
- 対象クラス:
ActiveSupport::ParameterFilter - この PR: https://github.com/rails/rails/pull/57556
- 関連仕様:
- Rails ではログに機密情報を残さないため、
config.filter_parametersと組み合わせてParameterFilterが広く使われており、その一貫性と安全性を保つためにも「入力ハッシュを直接書き換えない」ことが暗黙の重要な性質になっています。
- Rails ではログに機密情報を残さないため、
#57554 Test Object#with returns the block's result
マージ日: 2026/6/3 | 作成者: @hammadxcm
- 概要 (1-2文で)
このPRは、Object#withが「ブロックにselfを渡すだけでなく、そのブロックの戻り値自体を返す」ことをテストで明示的に保証するものです。プロダクションコードの変更はなく、テスト追加のみです。
- 変更内容の詳細
対象ファイル:
activesupport/test/core_ext/object/with_test.rb(+5/-0)
Object#with の振る舞いは以下のようになっています(概念的な例):
obj.with do |o|
# o は obj (self)
:result
end
# => :result が返ることを保証したい既存テストでは、「ブロックに self が渡されること」は確認していたものの、
ブロックの戻り値が Object#with の戻り値としてそのまま返ることが明示的に検証されていませんでした。
今回のPRで追加されたテストは、おおよそ次のようなことを確認しています(擬似コード):
result = some_object.with do |obj|
1234 # obj を使うかどうかに関係なく、この値を返す
end
assert_equal 1234, resultポイント:
- ブロック内で返している値が任意の値(オブジェクト)であること
- その値が
Object#withの戻り値として「そのまま」外に出てくること
→ 「withはブロックの評価結果を返す」という仕様をテストで固定
これにより、単に「self がブロックに渡されているだけ」ではなく、「ブロックの戻り値が with の戻り値である」というインターフェースが明確に保証されます。
- 影響範囲・注意点
影響範囲:
- ActiveSupport の
Object#withの挙動に関する“テストカバレッジ”のみが拡張されます。 - 実際の実装には一切変更がないため、既存アプリケーションの挙動は変わりません。
- ActiveSupport の
注意点:
- 今後
Object#withの実装を変更する場合、このテストにより「ブロックの戻り値をそのまま返すこと」が破壊されないようにする必要があります。 withを利用する側は、「ブロックの最後の評価結果がメソッドの戻り値になる」ことを前提としてコードを書いて良いことが、テストにより明確に裏付けられました。
- 今後
- 参考情報 (あれば)
- 対象メソッド:
Object#with(ActiveSupport コア拡張)- パターンとしては Kotlin の
apply/alsoや Ruby のtapに近いユーティリティで、「レシーバをブロックに渡しつつ、ブロックの評価結果を返す」系のメソッドです。
- パターンとしては Kotlin の
- PR番号: https://github.com/rails/rails/pull/57554
#57553 Fix Enumerable#in_order_of dropping nil elements when filter: false
マージ日: 2026/6/3 | 作成者: @hammadxcm
- 概要 (1-2文で)
Enumerable#in_order_ofにfilter: falseを指定した場合、本来は元の配列に含まれるnilも保持されるべきところが、誤って削除されていたバグが修正されました。sort_byベースの処理で不要なcompactを取り除き、仕様どおりnilを含めて返すようになっています。
- 変更内容の詳細
問題の挙動
Enumerable#in_order_of は、あるキー (key ブロックやシンボル) を元に要素を並べ替えるメソッドで、filter: オプションの挙動は以下のようにドキュメントされています。
filter: true(デフォルト)seriesに含まれるキーに該当する要素だけを返す(それ以外は除外)filter: falseseriesに含まれない要素も「落とさず」返す
今回のバグは、filter: false のときに、元の Enumerable に含まれていた本物の nil 要素まで compact によって削除されてしまっていた点です。
PR の説明より、以前と以後の挙動は以下のとおりです。
[3, nil, 1, 2].in_order_of(:itself, [1, 2, 3], filter: false)
# 変更前: [1, 2, 3] # nil が消えてしまう
# 変更後: [1, 2, 3, nil] # nil が正しく保持されるcompact が誤っていた理由
in_order_of の実装には大きく2パターンあります。
filter: trueのとき- 実装イメージ:ruby
grouped = collection.group_by(&key) ordered = grouped.values_at(*series) # series にないキーは nil になる ordered.flatten.compact values_at(*series)は、seriesの各エントリについて、対応する要素が存在しないとnilを返すため、compactでこれら「ダミーの nil」を消す必要があります。
- 実装イメージ:
filter: falseのとき- 実装イメージ(概念的):ruby
collection.sort_by do |element| # series 上の位置を優先し、見つからなければ後ろに回す end # => sort_by 自体は nil を生成しない - ここでは
sort_byで並び替えているだけなので、処理の途中で新たなnilが生成されることはありません。存在するのは「元から配列にいた本物の nil」だけです。 - したがって、このブランチで
compactを呼ぶと、本来保持すべきnilまで削除してしまい、ドキュメントの「filter: false のときは追加要素も落とさない」という契約を破っていました。
- 実装イメージ(概念的):
実際の変更内容
filter: false側の処理からcompactを 1 箇所削除- この挙動を保証するリグレッションテストを
enumerable_test.rbに追加- 想定されるテストイメージ:ruby
def test_in_order_of_with_filter_false_keeps_nil assert_equal [1, 2, 3, nil], [3, nil, 1, 2].in_order_of(:itself, [1, 2, 3], filter: false) end
- 想定されるテストイメージ:
- CHANGELOG にエントリを追加
- 影響範囲・注意点
- 影響範囲:
Enumerable#in_order_ofをfilter: false付きで利用しているコード全般。- 特に「
nilが除外される」ことを前提にワークアラウンドしていた場合、その挙動が変わります。
- 期待される正しい挙動:
filter: falseのときは、seriesに含まれない要素(nilを含む)も結果に残るようになります。
- 注意点:
- これまで「
in_order_of(..., filter: false)を使うとnilが落ちる」と誤解して利用していた場合、この PR マージ後にnilが残るようになるため、必要なら明示的にcompactするなどの対応が必要です。 filter: true側の挙動は変わっていないため、seriesに存在しないキーに対する「ダミーの nil」は引き続き削除されます。
- これまで「
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57553
- 該当コード:
activesupport/lib/active_support/core_ext/enumerable.rb - テスト:
activesupport/test/core_ext/enumerable_test.rb - 関連ドキュメント:
Enumerable#in_order_ofのfilterオプションの説明(Rails API ドキュメント / guides)
#57548 Test Range#sole with an endless range
マージ日: 2026/6/3 | 作成者: @hammadxcm
概要 (1-2文で)
このPRは、Range#soleが「終端なしの範囲(endless range)」に対してSoleItemExpectedErrorを投げることを確認するテストを追加するものです。既にあった「始端なしの範囲(beginless range)」向けテストに対応する形で、テストカバレッジを補完しており、本番コードの変更はありません。変更内容の詳細
対象機能:
ActiveSupportに追加されているRange#soleRange#soleは「範囲内に要素がちょうど1つだけ存在する場合にその要素を返し、それ以外の場合は例外を投げる」ユーティリティメソッドです(ActiveSupport::SoleItemExpectedErrorなど)。
既存の挙動:
Range#soleには、以下のようなガードがあります(PR説明より):rubyつまり、if self.begin.nil? || self.end.nil? raise ActiveSupport::SoleItemExpectedError end- beginless range:
(..1)→self.begin.nil? #=> true - endless range:
(1..)→self.end.nil? #=> trueのどちらも「無限範囲」とみなしてSoleItemExpectedErrorを投げる設計です。
- beginless range:
これまでのテスト状況:
- beginless range のケースはすでにテストされていました:ruby
assert_raises(ActiveSupport::SoleItemExpectedError) { (..1).sole } - 一方で、endless range のケース:rubyが例外を投げることは、テストでカバーされていませんでした。
(1..).sole
- beginless range のケースはすでにテストされていました:
このPRでの変更:
activesupport/test/core_ext/range_ext_test.rbに4行追加し、(1..).soleがActiveSupport::SoleItemExpectedErrorを投げることを確認するテストを追加。
- これにより、「
self.begin.nil? || self.end.nil?の両側」がテストで担保されるようになっています。
追加されるテストのイメージ(擬似コード):
rubydef test_sole_with_endless_range_raises assert_raises(ActiveSupport::SoleItemExpectedError) do (1..).sole end end
- 影響範囲・注意点
影響範囲:
- 変更はテストコードのみであり、ランタイム挙動や公開APIに変更はありません。
- すでに実装されている「無限 range に対する
Range#soleの例外スロー仕様」をテストで明示的に保証することによって、将来的なリグレッションを防止する効果があります。
注意点:
- 開発者視点では、
Range#soleは以下のように振る舞うことが前提として固まっていると考えてよいです:- 有限範囲で要素が1つのみ → その要素を返す
- 有限範囲で要素0個または2個以上 →
SoleItemExpectedErrorなどの例外 - beginless range (
(..x)) および endless range ((x..)): 「無限 range」とみなして常にSoleItemExpectedError
- 無限 range に対して
soleを使ってはいけない、という仕様がテストでより強固に固定されるため、将来この仕様を変えたい場合は、テスト変更を含めて明示的な設計変更が必要になります。
- 開発者視点では、
- 参考情報 (あれば)
- 対象メソッド:
ActiveSupportのRange#sole
ドキュメント:- Rails ガイドでは
soleは主にActiveRecord::Relation#soleとして紹介されますが、Enumerable#sole/Range#soleも同様の振る舞いをします。
- Rails ガイドでは
- 関連仕様:
- Ruby 2.6以降で導入された beginless range
(..x)と endless range(x..)に対し、Rails は「無限集合」とみなしてsoleを禁止するポリシーを取っていることが、このPRのテストからも読み取れます。
- Ruby 2.6以降で導入された beginless range
#57532 Fix grouped calculations by a belongs_to association with a composite primary key model
マージ日: 2026/6/2 | 作成者: @55728
- 概要 (1-2文で)
belongs_to先が複合主キーを持つモデルの場合にgroup(:association).countなどの集計がArgumentErrorで落ちていた問題を修正した PR です。従来の単一カラム主キーと同様に、「関連オブジェクトをキーにしたグループ集計」が複合主キーでも動作するようになります。
- 変更内容の詳細
何が問題だったか
次のようなモデル構成を考えます。
class Order < ApplicationRecord
self.primary_key = [:shop_id, :id] # 複合主キー
end
class Book < ApplicationRecord
belongs_to :order, foreign_key: [:shop_id, :order_id]
endこのとき、
Book.group(:order).countを実行すると、本来は
{ #<Order ...> => 3, #<Order ...> => 5, ... }のように「Order オブジェクトをキーとしたハッシュ」が返ってきてほしいところですが、実際には以下のエラーになっていました。
ArgumentError: Expected corresponding value for ["shop_id", "id"] to be an Array単一主キー版(例:Comment.group(:post).count)は以前から動いていて、複合主キーのときだけ壊れている状態でした。
原因の詳細
ActiveRecord::Relation#calculate 系メソッドで「関連をキーにしたグループ集計」を行う場合、内部では execute_grouped_calculation が呼ばれ、次のような処理が行われます(擬似コード):
# 集計結果の生データから、グルーピングに使ったキーの値を抜き出す
key_ids = calculated_data.collect { |row| row[group_aliases.first] }
# 取り出したキーをもとに、関連レコードをまとめて取得
key_records = association.klass.base_class.where(
association.klass.base_class.primary_key => key_ids
)
records_by_id = key_records.index_by(&:id)単一主キーであれば、
primary_keyは"id"(文字列)group_aliases.firstは、idに対応する集計結果のカラム名key_idsは[1, 2, 3, ...]のような一次元配列where(id: [1,2,3,...])となり問題なく動作
という流れで正しく動きます。
しかし複合主キーのときは、
primary_keyは["shop_id", "id"](配列)- 実際に GROUP BY されているカラムは、
group_fields = Array(association.foreign_key)で、複数列(例:["shop_id", "order_id"]) - ところが
key_idsの取得ではgroup_aliases.firstしか使っておらず、1 列分だけ 抜き出してしまう
その結果、内部的にだいたい次のような形の where が組み立てられます。
where(["shop_id", "id"] => [1, 2, 3, ...])このとき、Active Record の PredicateBuilder は「複合主キーなら [[shop_id, id], [shop_id, id], ...] のようなタプル配列が来るはず」と期待しているのに、実際には単なるスカラ値の配列([1,2,3,...])が渡されるため、
ArgumentError: Expected corresponding value for ["shop_id", "id"] to be an Arrayを投げてしまっていました。
修正内容
この PR では「関連先モデルが複合主キーを持っている場合」に限り、次のように動作を変えています。
- これまで:
group_aliases.firstだけを使ってキーを 1 列分だけ拾っていた - 修正後: 複合主キーの全カラムに対応する GROUP BY 列を 1 行ごとにタプルとして集める
つまり、ざっくり言うと「key_ids に [["shop1", 1], ["shop1", 2], ...] のような“複合キーの配列”を入れる」ようにして、where(primary_key => key_ids) が
where(["shop_id", "id"] => [["shop1", 1], ["shop1", 2], ...])という形になり、PredicateBuilder の期待する形に合うようにしています。
その後の
key_records.index_by(&:id)はもともと複合主キーに対しても「id メソッドが複合キーのタプル([shop_id, id])を返す」設計になっており、今回の修正と整合的です。
単一カラム主キーについては既存の分岐ロジックがそのまま維持されており、挙動は変わりません。
テスト
activerecord/test/cases/calculations_test.rb に以下を満たす回帰テストが追加されています。
belongs_to先が複合主キーを持つ- それを
group(:association).countでグループ化 - 結果のキーが「関連オブジェクト(複合主キーのモデル)」になっていることを検証
このテストは:
- 現行 main ブランチでは
ArgumentErrorで落ちる(red) - 修正適用後は sqlite3 / postgresql の両方でグリーン
であることが確認されています。
- 影響範囲・注意点
影響する機能
ActiveRecord::Relationのグループ集計(count,sum,average,minimum,maximumなど)で、グループキーにbelongs_to関連を指定したケース- かつ、その
belongs_toの参照先モデルが 複合主キー を持つ場合 - 例:
Book.group(:order).countやBook.group(:order).sum(:price)など
単一主キーのモデルに対しては挙動は変わらず、後方互換性を壊す変更ではありません。
複合主キー関連を使っているアプリでは:
- これまで
group(:association)を避けて自前で JOIN + GROUP BY していたようなケースを、Rails 標準の API に置き換えられる可能性があります。 - 逆に、アプリ側でこのバグを前提に「例外発生」を利用したワークアラウンドを書いていた場合は、その挙動が変わる点に注意が必要です(一般には少ないはずです)。
- これまで
複合主キーサポート全体としては、まだ Rails の公式サポートが「どこまでを保証するか」という課題は残っているものの、少なくともこの経路(
group(:belongs_to)→ 関連オブジェクトキーでの計算結果)は動作するようになります。
- 参考情報 (あれば)
変更ファイル
activerecord/lib/active_record/relation/calculations.rbexecute_grouped_calculation付近で、複合主キー対応のキー収集ロジックを追加
activerecord/test/cases/calculations_test.rb- 複合主キー
belongs_toを使ったgroup(:association).countの回帰テストを追加
- 複合主キー
activerecord/CHANGELOG.md- バグ修正としてエントリ追加
関連する概念
- Active Record の「複合主キー」は依然として標準機能としては限定的な扱いですが、この PR のように局所的なサポート改善が徐々に進んでいます。
index_by(&:id)は複合主キーを持つモデルに対しても「idが複合キーの配列を返す」ことを前提にしており、今回の形状修正とよく噛み合っています。
#57539 Reverse default_order in reverse_order instead of discarding it
マージ日: 2026/6/2 | 作成者: @55728
- 概要 (1-2文で)
Rails のActiveRecord::Relation#reverse_orderが、default_orderだけで並び替えられている場合にその並び順を正しく反転せず、主キー降順に差し替えてしまっていたバグを修正する PR です。明示的なorderと同様に、default_orderもreverse_orderで正しく反転されるようになります。
- 変更内容の詳細
これまでの挙動
default_order のみが設定されている relation に対して reverse_order を呼ぶと、default_order が無視されて PK(主キー) 降順に置き換えられていました。
Model.default_order("name ASC").to_sql
# => "... ORDER BY name ASC"
Model.default_order("name ASC").reverse_order.to_sql
# => "... ORDER BY \"models\".\"id\" DESC" # 本来は name DESC になってほしい
Model.order("name ASC").reverse_order.to_sql
# => "... ORDER BY name DESC" # 明示的 order の場合は正しく動く原因は、reverse_order! が order_values だけ を見て処理していたことです。
def reverse_order! # :nodoc:
orders = order_values.compact_blank
self.order_values = reverse_sql_order(orders)
self
enddefault_orderだけが設定されている relation ではorder_valuesは空配列[]- 空配列を
reverse_sql_order([])に渡すと、「明示的な順序なし」とみなされ、内部的な_reverse_order_columns(PK DESC) が返る - それが
order_valuesに設定されるため、その後のbuild_orderはdefault_order_valuesを一切見なくなる
→ 結果として、「default_orderを消して PK 降順にする」誤った挙動になっていた
そもそも default_order 機能を導入したコミットでは、build_order や ordered_relation は「order_values が空のときは default_order_values を使う」というフォールバックを持つように変更されていましたが、reverse_order! 側の知識更新が漏れていた、という位置づけです。
修正内容
reverse_order! にも default_order_values の存在を考慮させ、明示的 order がない場合は default_order を反転するようにしました。
def reverse_order! # :nodoc:
orders = order_values.compact_blank
if orders.empty? && (default_orders = default_order_values.compact_blank).any?
self.default_order_values = reverse_sql_order(default_orders)
else
self.order_values = reverse_sql_order(orders)
end
self
endポイント:
order_valuesに有効な order がある場合
→ これまで通りorder_valuesを反転 (明示的order優先の挙動はそのまま)order_valuesが空で、default_order_valuesが存在する場合
→default_order_valuesをreverse_sql_orderに通してdefault_order_values自体を反転
(ここが新挙動)
この実装により、以下が成立します:
# 1. default_order だけの場合 → その default_order が反転される
Model.default_order("name ASC").reverse_order.to_sql
# => "... ORDER BY name DESC"
# 2. default_order があるが、明示的 order を追加した場合 → 明示的 order を反転
Model.default_order("name ASC").order("id").reverse_order.to_sql
# => "... ORDER BY id DESC" # default_order はあくまでフォールバック
# 3. default_order も明示的 order もない場合
# → 今まで通り PK DESC か IrreversibleOrderError の挙動を維持
Model.all.reverse_order.to_sql
# => これまでと同じ PK 降順 or IrreversibleOrderErrorテスト
activerecord/test/cases/relations_test.rb に以下のような意図のテストが追加されています:
default_order("title ASC").reverse_orderの SQL がORDER BY title DESCになること- 明示的な
order("title ASC")のreverse_orderと同じ並び替え指定になること - 主キーによるフォールバックが用いられていないこと (PK 名が含まれないこと)
テストは sqlite3/postgresql/mysql2/trilogy でグリーンとのことです。
- 影響範囲・注意点
- 影響するのは
default_orderを使っているコードで、かつその relation に対してreverse_orderを呼んでいるケース に限定されます。- 具体例:
- モデルやスコープで
default_order "created_at ASC"を定義し、ビューや API でreverse_orderを使って「新しい順」「古い順」を切り替えている場合など
- モデルやスコープで
- 具体例:
- これまで:
default_orderのみ →reverse_orderすると PK DESC でソート (事実上バグ・silent wrong result)
- これから:
default_orderのみ → その default_order を単純反転
- 明示的
orderを使っているコードの挙動は変わりません (order_valuesが優先されるロジックはそのまま)
注意点 / マイグレーション的観点:
- もし既存アプリが「このバグ前提の挙動 (default_order を無視して PK で降順になる)」を暗に利用していた場合、
reverse_orderの結果が変わる可能性があります。- 例:
default_order("name ASC")を定義したが、「reverse_orderしたらなぜか id DESC になる」挙動を仕様として組み込んでいた場合
- 例:
- 通常は「
default_orderを反転してほしい」と考える方が自然なので、ほとんどのアプリにとっては バグ修正による改善 になり、破壊的と見なさないケースが多いと思われますが、挙動が変わる点は留意が必要です。
- 参考情報 (あれば)
- この PR とは別に、同じ
default_order機能周りで以下の既知バグがあると記載されています:has_many default_order:が、関連がロード済みのときに無視される問題 (#57538)
→ こちらはrelation/query_methodsではなくアソシエーション層の問題で、今回の修正とは独立しています。
- 実装の観点では、「
build_order/ordered_relationと同様、reverse_order!もdefault_order_valuesを考慮するようにした」という一貫性の確保が主眼です。
#57546 Test ContentDisposition.format class method
マージ日: 2026/6/2 | 作成者: @hammadxcm
概要 (1-2文で)
ActionDispatch::Http::ContentDisposition.formatクラスメソッドに対して、これまで存在していなかった直接のテストを追加した PR です。プロダクションコードの変更は一切なく、テストコードのみの追加です。変更内容の詳細(あればサンプルコードも含めて)
対象
ActionDispatch::Http::ContentDisposition.formatというクラスメソッド- Content-Disposition ヘッダ文字列を組み立てるための公開ショートカットメソッド
- データストリーミング処理や ActiveStorage 内部で利用される想定の API
これまでの問題点
- 既存テストは
ActionDispatch::Http::ContentDispositionのインスタンスを生成し、#to_sを呼ぶ形でのみ挙動を検証していた - そのため、公開 API としての
.format自体がテストされていなかった(未カバー)
- 既存テストは
今回の変更
actionpack/test/dispatch/content_disposition_test.rbに.format用のテストを追加(+10行).formatを以下の2パターンでテスト:- ファイル名なしでの呼び出し
- ファイル名ありでの呼び出し
想定されるテストイメージは以下のような形です(概念的なサンプル):
rubydef test_format_without_filename header = ActionDispatch::Http::ContentDisposition.format(disposition: "attachment") assert_equal "attachment", header end def test_format_with_filename header = ActionDispatch::Http::ContentDisposition.format( disposition: "attachment", filename: "report.pdf" ) assert_equal 'attachment; filename="report.pdf"; filename*=UTF-8\'\'report.pdf', header end実際の期待値文字列やオプションは内部仕様に依存しますが、PR では「
.formatを直接呼び出し、その戻り値(ヘッダ文字列)を検証する」形でカバレッジを追加しています。
- 影響範囲・注意点
影響範囲
- Rails 利用アプリの挙動には変更なし
ActionDispatch::Http::ContentDisposition.formatを既に使っているコードへの互換性影響もなし- 主な影響は「テストカバレッジが正しく
.formatにも及ぶようになった」ことのみ
注意点
.formatの挙動自体は変わっていないため、これを契機に API の仕様変更などが行われたわけではない- ただし、今後
.formatの実装を変更した際にはこのテストが壊れることでリグレッションを検知できるようになったため、**.formatの振る舞いが事実上「仕様として固定化されやすくなった」**とも言える .formatを直接利用しているライブラリやアプリの開発者にとっては、今後このテストが仕様の参考になる
- 参考情報 (あれば)
- 対象クラス:
ActionDispatch::Http::ContentDisposition - 主な利用箇所の例(Rails 本体側)
- ストリーミングレスポンス (
send_data,send_fileなど) のヘッダ生成 - ActiveStorage のファイルダウンロードレスポンスヘッダ生成
- ストリーミングレスポンス (
- この PR: https://github.com/rails/rails/pull/57546
#57544 Test ValidationError exposes model and message
マージ日: 2026/6/2 | 作成者: @hammadxcm
- 概要 (1–2文で)
ActiveModel::ValidationErrorに対するテストを拡充し、validate!実行時に発生する例外から#modelとエラーメッセージが期待どおり取得できることを検証する変更です。プロダクションコードの変更はなく、テストコードのみの追加です。
- 変更内容の詳細
対象ファイル:
activemodel/test/cases/validations_test.rb(+12/-0)
主なポイント:
ActiveModel::ValidationErrorはドキュメント上、以下を保証しています:#modelメソッドで、バリデーションに失敗したモデルインスタンスを取得できる。- 例外メッセージは
"Validation failed: ..."という形式で生成される。
これまでの
validate!のテストは「例外が発生すること」だけを確認しており、- どのモデルインスタンスが
ValidationErrorに入ってくるか (error.model) - 例外メッセージ (
error.message)
については検証していませんでした。
- どのモデルインスタンスが
今回のPRでは、
validate!を呼び出してActiveModel::ValidationErrorが発生したときに、error.modelが元のモデルインスタンスと同一であることerror.messageが"Validation failed: ..."形式(かつ、具体的なエラー内容を含む)になっていること
を明示的にアサートするテストが追加されています。
おおよそのイメージとして、以下のようなテストが追加されていると考えられます(疑似コード):
def test_validate_bang_exposes_model_and_message
user = User.new # 何らかのバリデーション付きモデル
assert_raises(ActiveModel::ValidationError) do
user.validate!
end.tap do |error|
assert_equal user, error.model
assert_match "Validation failed:", error.message
# 必要に応じて特定のエラーメッセージ文字列も確認
end
end実際のPRでは上記と同等のアサーションを activemodel/test/cases/validations_test.rb に数行追加しているだけで、テストケースは 1 つ増える程度の小さな差分です。
- 影響範囲・注意点
影響範囲:
- テストコードのみの変更のため、Rails を利用するアプリケーションの挙動や公開APIには一切影響しません。
- CI でのテストカバレッジ向上と、
ActiveModel::ValidationErrorの仕様に対する回帰検知能力が高まります。
注意点:
- このPRが前提としている仕様(
ValidationError#modelと"Validation failed: ..."形式のメッセージ)はすでにドキュメント化・実装済みのものです。
→ もし将来的にこの仕様を変更する場合(例: メッセージフォーマットを変える)には、今回追加されたテストが落ちることになります。 - 独自に
ActiveModel::ValidationErrorをラップ・再生成しているコードがある場合は、Rails 本体と同じインターフェース (#modelとメッセージ形式) を維持しているか確認しておくと、今後の互換性維持に役立ちます。
- このPRが前提としている仕様(
- 参考情報 (あれば)
対象クラス:
ActiveModel::ValidationError- ドキュメント: Active Model のバリデーションエラー用例外クラスで、
record.validate!やmodel.validate!でバリデーションに失敗したときに発生します。 - 典型的な利用例:ruby
begin user.validate! rescue ActiveModel::ValidationError => e # e.model で失敗した user インスタンスにアクセス # e.message は "Validation failed: Name can't be blank" など end
- ドキュメント: Active Model のバリデーションエラー用例外クラスで、
このPRの位置付け:
- 「ドキュメントされている仕様をテストでカバーする」タイプの変更であり、今後のリファクタリングやメッセージ生成ロジック変更時に、仕様が誤って壊されないようにするための安全網になっています。
#57543 Test ArrayInquirer#any? without candidates
マージ日: 2026/6/2 | 作成者: @hammadxcm
- 概要 (1-2文で)
ActiveSupport::ArrayInquirer#any?を引数なしで呼び出した場合の挙動(配列に要素があるかどうかを返す)を確認するテストが追加された PR です。
本番コードへの変更はなく、テストカバレッジのみが拡充されています。
- 変更内容の詳細
- 対象クラス:
ActiveSupport::ArrayInquirer - 対象メソッド:
#any?
ArrayInquirer#any? にはもともと以下の3パターンの利用形態があります:
引数あり:
rubyinquirer.any?(:phone, :tablet)→ 渡した候補のうち一つでも含まれているか判定。
ブロックあり:
rubyinquirer.any? { |v| v.to_s.start_with?("p") }→ 内部配列の要素に対してブロック条件を満たすものがあるか判定。
引数・ブロックなし(今回テストを追加したケース):
rubyvariants = ActiveSupport::ArrayInquirer.new([:phone, :tablet]) variants.any? # => true empty_variants = ActiveSupport::ArrayInquirer.new([]) empty_variants.any? # => false→
Array#any?と同様に、「配列が空でないかどうか」を返す。
今回の PR では、この「引数なし」のケースについて、以下のようなテストが activesupport/test/array_inquirer_test.rb に追加されています(イメージ):
def test_any_without_arguments_for_populated_inquirer
inquirer = ActiveSupport::ArrayInquirer.new([:phone, :tablet])
assert_equal true, inquirer.any?
end
def test_any_without_arguments_for_empty_inquirer
inquirer = ActiveSupport::ArrayInquirer.new([])
assert_equal false, inquirer.any?
end実際には 5 行程度のテスト追加のみで、本体コードには一切手が入っていません。
- 影響範囲・注意点
影響範囲:
- 実行時の挙動は一切変わりません(既存の
ArrayInquirer#any?の仕様をテストで明示しただけ)。 - すでに
variants.any?のような引数なし呼び出しを利用しているコードの挙動はそのままです。
- 実行時の挙動は一切変わりません(既存の
注意点:
- この PR により、「
ArrayInquirer#any?は引数・ブロックなしの場合、Array#any?と同じく『要素が1つでもあれば true』を返す」という挙動がテストで固定化されます。 - 将来的にこの挙動を変えようとするとテストが落ちるため、仕様としての重みが増したことになります。
- テストのみの変更なので、パフォーマンスや互換性への実務的なリスクはありません。
- この PR により、「
- 参考情報 (あれば)
- 対象メソッド:
ActiveSupport::ArrayInquirer#any?
Rails ガイド等でArrayInquirerが "variants" などのフラグ判定を簡潔に書くためのユーティリティとして紹介されることがありますが、その一部としてany?がArray#any?と互換の呼び出し形態を持つことが、この PR によりテストで明確化されました。
#57545 Test BigInteger serializing string values
マージ日: 2026/6/2 | 作成者: @hammadxcm
- 概要 (1-2文で)
ActiveModel::Type::BigInteger#serializeが文字列をどう扱うかについて、既存の挙動をテストで明示的にカバーする PR です。プロダクションコードの変更はなく、テストが 7 行追加されただけです。
- 変更内容の詳細
対象: activemodel/test/cases/type/big_integer_test.rb
この PR では、ActiveModel::Type::BigInteger#serialize に対する以下の「文字列入力時の挙動」をテストで保証しています。
- 数値文字列の場合
- 例:
"123"→123(Integer)
- 例:
- 先頭が数値だが途中から非数値文字が含まれる場合
- 例:
"123abc"→123に切り詰められる
- 例:
- 数値を含まない文字列の場合
- 例:
"abc"→nil
- 例:
テスト側では、概ね次のようなケースが追加されていると考えられます(擬似コード):
def test_serialize_numeric_string
type = ActiveModel::Type::BigInteger.new
assert_equal 123, type.serialize("123")
end
def test_serialize_leading_numeric_string
type = ActiveModel::Type::BigInteger.new
assert_equal 123, type.serialize("123abc")
end
def test_serialize_non_numeric_string
type = ActiveModel::Type::BigInteger.new
assert_nil type.serialize("abc")
endこれにより、これまで Integer や Integer ライクな値("1_000" を to_i するとか、BigDecimal 等)についてしかテストされていなかった部分に、「文字列入力時の分岐」がきちんと含まれるようになっています。
- 影響範囲・注意点
影響範囲
- テストコードのみの追加であり、本体コード (
ActiveModel::Type::BigInteger) の挙動変更はありません。 - 現在すでに存在している「文字列→整数(or nil)」への変換仕様を明文化・固定化する意味合いがあります。
- テストコードのみの追加であり、本体コード (
注意点・仕様として改めて意識すべき点
"123abc"のような「先頭が数値の文字列」は、エラーではなく 123 としてシリアライズされる
→ 予想に反してサイレントに切り捨てられる可能性があるため、バリデーションや型チェックで厳密性を求める場合は別途対処が必要。- 完全に非数値の文字列は
nilになる
→ DB カラムが NOT NULL / 外部キーなどの場合、このnilが後続の処理でエラーを引き起こす可能性がある。 - 今回のテスト追加により、この挙動は今後のリファクタリング時にも「期待される既存仕様」として守られる可能性が高くなります。
- 参考情報 (あれば)
- 該当クラス:
ActiveModel::Type::BigInteger- Active Record の bigint カラムや類似の用途で使われる型オブジェクトで、
serializeは「Ruby オブジェクト → DB 送信値」への変換を担当します。
- Active Record の bigint カラムや類似の用途で使われる型オブジェクトで、
- 変換ロジックの背景:
- ActiveModel の数値系 Type は、多くが
to_i/to_sベースで文字列を解釈しており、その標準挙動(先頭数値だけ読む、非数値は 0 / nil 扱い等)に依存していることが多いです。
- ActiveModel の数値系 Type は、多くが
- 実務上の補足:
- 「ユーザー入力などで混入しうる不正な文字列を BigInteger にそのまま渡した場合、どうなるか」を把握・テストで保証したいアプリケーションでは、この PR のようなテストを自前のアプリ層で追加しておくと挙動がブレにくくなります。
#57541 Test truncate when omission is longer than truncate_to
マージ日: 2026/6/2 | 作成者: @hammadxcm
- 概要 (1-2文で)
String#truncateにおいて、:omissionがtruncate_toより長い場合の挙動(省略記号だけが返り、結果文字列がtruncate_toを超えるケース)をテストでカバーする PR です。プロダクションコードの変更はなく、テスト追加のみです。
- 変更内容の詳細
対象メソッド:
ActiveSupport::CoreExtensions::String::Filters#truncate(String#truncate)既存仕様(ドキュメントに記載されている挙動):
truncate_toで指定した最大長を超えないように文字列を切り詰める- ただし「元の文字列 (
text) と:omissionの両方がtruncate_toより長い場合」は例外的に、結果の長さがtruncate_toを超えうる、という仕様になっている - 内部的には「offset が負になる」パスでこの挙動が発生するが、その分岐がテストされていなかった
本PRでの変更:
activesupport/test/core_ext/string_ext_test.rbに、以下の条件を満たすテストケースが追加された::omissionの文字列長 >truncate_to- その結果として、戻り値が
:omissionだけになることを確認する
- 行数としては +5 行程度の小さなテスト追加のみ
サンプルイメージ(擬似コード・概念的な例):
rubytext = "Hello world" omission = "[TRUNCATED]" # 例: 長さ 11 truncate_to = 5 result = text.truncate(truncate_to, omission: omission) # このケースでは result == "[TRUNCATED]" となり、 # 結果の長さ 11 > truncate_to 5 となることが仕様として許容される。この「
truncate_toより長い:omissionがそのまま返る」挙動が、既にドキュメントに明記されている仕様であり、それに対するテストが追加された形です。
- 影響範囲・注意点
影響範囲:
- 本PRはテストコードのみの変更であり、ランタイム挙動や既存アプリケーションへの影響はありません。
- ただし、「
:omissionが長い場合に結果がtruncate_toを超える」という現在の仕様がテストで固定化されたため、将来的にこの仕様を変える場合は互換性問題として扱う必要が出てきます。
開発者が意識すべきポイント:
String#truncateを利用する際、「結果文字列が常にtruncate_to以下になる」と思い込むとバグにつながります。- 特に UI レイアウトや DB カラム幅など、「結果文字列長を厳密に制限したい」場面では、次のような考慮が必要です:
omissionをtruncate_to以下の長さに抑える- あるいは、
truncateの後にさらにmb_chars.limitなどで二重に長さチェックを行う
- 今回のテストにより、この挙動は「仕様として意図的にサポートされている」ことがより明確になります。
- 参考情報 (あれば)
- Rails API ドキュメント(String#truncate)には、以下のような文言があります(要旨):
- 「結果の全体長は
truncate_toを超えません。ただし、textと:omissionの双方がtruncate_toより長い場合はこの限りではありません。」
- 「結果の全体長は
- このPRは、そのドキュメント記載の「例外ケース(offset が負になる分岐)」に対するテストカバレッジを補完するものです。
- PR番号: https://github.com/rails/rails/pull/57541
#57540 Test Array offset accessors when out of bounds
マージ日: 2026/6/2 | 作成者: @hammadxcm
- 概要 (1-2文で)
Array#second〜#forty_twoおよび#second_to_last/#third_to_lastが、配列の長さが足りない場合にnilを返す挙動についてテストを追加した PR です。プロダクションコードの変更はなく、テストコードのみの追加です。
- 変更内容の詳細
- 対象:
activesupport/test/core_ext/array/access_test.rb - 目的: 「範囲外アクセス時に
nilを返す」というパスをテストでカバーすること。
これまで存在していたテスト test_specific_accessor は、42 要素の配列を使って #second〜#forty_two を確認していたため、常に有効なインデックスにアクセスしており、「配列が短い場合に nil を返す」動作は検証されていませんでした。
今回の PR では、長さが不足している配列に対して、以下のメソッドが nil を返すことを確認するテストが追加されています。
- 先頭側のエイリアス
Array#second〜#forty_two
- 末尾側のエイリアス
Array#second_to_lastArray#third_to_last
具体的なイメージとしては、例えば次のようなパターンを確認するテストが加えられています(実際のコードイメージ):
def test_specific_accessor_out_of_bounds
ary = [1] # 意図的に短い配列
assert_nil ary.second # 2番目の要素は存在しない
assert_nil ary.third # 3番目も存在しない
# ...
assert_nil ary.forty_two # 42番目ももちろん存在しない
assert_nil ary.second_to_last # 要素1つでは「後ろから2番目」は存在しない
assert_nil ary.third_to_last # 同様に存在しない
endポイントは、配列の長さ < 要求された n 番目/後ろから n 番目の位置の場合に、例外ではなく nil を返すことを明示的にテストしている点です。
- 影響範囲・注意点
- 影響範囲:
- 変更はテストコードのみであり、
Arrayの拡張メソッド (second,third, ...,forty_two,second_to_last,third_to_last) の実装には一切手を入れていません。 - 既存アプリケーションの挙動には影響ありません。
- 変更はテストコードのみであり、
- 開発者視点での意義:
- 以前から「範囲外アクセス時は
nilを返す」という仕様で動いてはいたものの、それを保証するテストがなかったため、将来のリファクタリング等でこの挙動が壊れた場合に検知できるようになったといえます。 - これにより、「配列が短いかもしれないが
array.thirdなどをそのまま呼んでnilチェックで扱う」といったコードパターンに対して、仕様保証が強化されます。
- 以前から「範囲外アクセス時は
注意点として、テストが追加されたことで明示的に仕様が固定されたとも解釈できるため、将来的に「範囲外なら例外を投げたい」といった互換性を壊す変更は、より困難になります(少なくとも大きなBreaking Change扱いになる)。
- 参考情報 (あれば)
- 対象メソッドは Active Support の
ArrayInquirerではなく、ActiveSupport::CoreExtensions::Array::Access系の拡張 (Array#secondなど) です。 - 関連する既存テスト:
activesupport/test/core_ext/array/access_test.rb内のtest_specific_accessor
こちらは「配列が十分に長い場合に、各メソッドが正しい要素を返すこと」を確認しており、今回の PR で「短い配列時はnil」という逆パターンのテストが補完されました。
#57542 Test Mime::Type#html? predicate
マージ日: 2026/6/2 | 作成者: @hammadxcm
- 概要 (1-2文で)
Mime::Type#html?メソッドの挙動(:htmlシンボル、"html"を含むMIMEタイプ文字列、それ以外でfalse)を直接検証するテストが追加されたPRです。プロダクションコードの変更はなく、テストカバレッジを補完する目的の修正です。
- 変更内容の詳細
Mime::Type#html? は以下のような条件で真偽値を返すメソッドです:
Mime[:html]のようにシンボルが:htmlの場合 →true- MIMEタイプ文字列に
"html"が含まれている場合
例:Mime::Type.new("application/xhtml+xml")→true - 上記いずれにも該当しない場合 →
false
これまでこのメソッドに対する「直接の」テストが存在しなかったため、以下を対象とするテストが actionpack/test/dispatch/mime_type_test.rb に追加されています(+7行):
- シンボルが
:htmlである場合に#html?がtrueを返すこと - MIMEタイプ文字列に
"html"を含む ("application/xhtml+xml"など) 場合にtrueを返すこと - それ以外のMIMEタイプ (
text/plainなど) ではfalseになること
※ PR本文から読み取れるテストイメージ(概念的な例):
def test_html_predicate
assert Mime[:html].html?
xhtml = Mime::Type.new("application/xhtml+xml")
assert xhtml.html?
plain = Mime::Type.new("text/plain")
assert_not plain.html?
endあくまで例示ですが、このような「trueパス2種類+falseパス1種類」を明示的にカバーするテストが追加されています。
- 影響範囲・注意点
- 影響範囲:
- 影響はテストコードのみで、本番コード・APIの仕様変更はありません。
- 既に実運用されている
Mime::Type#html?の振る舞いを、テストとして「仕様として固定」した形になります。
- 注意点:
- 将来的に
html?の判定仕様(例:"html"部分一致をもっと厳しく/緩くする)が変わる場合、このテストが落ちることで、仕様変更が意図的かどうかを確認するきっかけになります。 - カスタムMIMEタイプや
xhtml系の取り扱いでhtml?の真偽に依存しているコードがある場合、その現在の挙動がテストによって明示されたと捉えられます(「"html"を含めば true になる」という挙動が正式にテストで保証される)。
- 将来的に
- 参考情報 (あれば)
- 対象メソッド:
Mime::Type#html?- 用途の典型例:
- コントローラやミドルウェア内で
request.format.html?のように利用し、「HTML向けレスポンスかどうか」を判定する場面など。
- コントローラやミドルウェア内で
- 用途の典型例:
- 関連ファイル:
actionpack/test/dispatch/mime_type_test.rb- 各種
Mime::Type振る舞いのテストが集約されているファイルで、その一部として#html?が明示的にテスト対象に加わった形です。
- 各種
#56422 ActionView::TestCase#render resets rendered
マージ日: 2026/6/2 | 作成者: @drjayvee
- 概要 (1-2文で)
ActionView::TestCase の#renderが、複数回呼び出したときにrenderedの内容を正しくリセットできていなかったバグを修正した PRです。メモ化導入 (#51093) によって壊れていた既存の挙動を復元しつつ、content_classをカスタム実装しているケースにも配慮した実装になっています。
- 変更内容の詳細
問題の背景
ActionView::TestCaseでは、renderを呼ぶと結果がrenderedに溜まっていきます。- 以前は
render内で@rendered.clearを呼ぶことで、「テストごとのrender呼び出しごとに内容をリセットする」という挙動になっていました。 - #51093 でメモ化が導入された際、この「
renderがrenderedをリセットする」という挙動が壊れ、renderを複数回呼ぶと出力が意図せず蓄積されてしまう状態になっていました。
今回の修正の要点
説明文から読み取れるポイント:
ActionView::TestCase#renderがrenderedをリセットする振る舞いを復元した。- 以前は
@rendered.clearを直接呼んでいたが、メモ化の影響でそれが正しく動かなくなっていた。 - さらに、ユーザーが
content_classクラス属性を使って@renderedのクラスを差し替えできる仕様があるため、- そのオブジェクトが 必ずしも
#clearを実装しているとは限らない(契約上必須なのは#<<だけ)。 - したがって
@rendered.clearを前提とする実装は安全ではない。
- そのオブジェクトが 必ずしも
- そこで、「
@renderedをクリアする」のではなく、「新しい@renderedインスタンスを毎回作る」というアプローチを採用している。
コードのイメージ(実際のコードを単純化した擬似例):
# 以前 (概念的な挙動)
def render(*args)
@rendered ||= content_class.new
@rendered.clear # この辺が壊れていた & clear を必須としてしまう
super
end
# 今回の修正後イメージ
def render(*args)
# 毎回新しいインスタンスを作ることで、「リセット」を保証する
@rendered = content_class.new
super
endテスト (actionview/test/template/test_case_test.rb) では:
renderを同じテスト内で複数回呼んだときに、renderedが前回の結果を持ち越さないこと。- メモ化導入後に壊れていた挙動が正しく戻っていること。
などを確認するテストが追加/調整されています。
CHANGELOG.md には:
- ActionView に関するバグフィックスとして、
ActionView::TestCase#renderがrenderedを正しくリセットするようになった旨- (必要であれば関連 Issue / PR 番号) が追記されています。
- 影響範囲・注意点
- 対象:
ActionView::TestCaseを使っている ビューのテストコード。 - 影響内容:
renderを同一テスト内で複数回呼び出したときのrenderedの内容が、前回の結果を引き継がず、毎回リセットされるようになります。- これはもともとの想定挙動であり、「壊れていたものが直る」形なので、Rails の従来の仕様に沿った変更です。
content_classをカスタム実装している場合:- これまで
#clearが実装されていなくても、今回の修正により#clearの有無に依存しない 実装になっているため、むしろ安全側に振られています。 content_class#initializeが毎回呼ばれる前提になるので、もし重い初期化処理をしている場合は、パフォーマンス上の影響がごくわずかに増える可能性がありますが、通常のテスト用途では問題ないレベルと考えられます。
- これまで
- 参考情報 (あれば)
- 関連 Issue / PR:
- #56235: 本件の詳細背景が議論されている Issue(この PR 内で参照されている)
- #51093:
ActionView::TestCaseにメモ化を導入し、本バグの原因となった変更
- カスタム
content_classを使っているプロジェクトでは、renderを複数回呼んだときのrenderedの値(およびそのクラスのライフサイクル)が、今回の仕様と整合しているかを軽く確認しておくと安心です。
#57534 Fix disable_joins through associations grouping by composite key
マージ日: 2026/6/2 | 作成者: @55728
- 概要 (1-2文で)
disable_joins: trueを指定したhas_many/has_one :throughで、途中に order があり、かつ関連先が複合キー(複合 primary key / foreign key)を使っている場合に、結果が黙って空配列になってしまうバグを修正しています。
複合キーを使う関連の in-memory グルーピング処理で、キーの扱い方を正しく複合キー対応にすることで、取得済みレコードが消えてしまう問題を解消しています。
- 変更内容の詳細
問題のパス
disable_joins: true を使うと、AR は JOIN を避けるため、ActiveRecord::Associations::DisableJoinsAssociationScope によって一つの JOIN クエリを複数クエリに分解します。
この際、関連チェーンのどこかに order がついていて、かつ最終スコープ側には order がない場合、その order は Ruby 側での in-memory ソート・グルーピングに回されます。
その処理は ActiveRecord::DisableJoinsAssociationRelation#load にあり、問題のコードは:
records_by_id = records.group_by do |record|
record[key]
end
records = ids.flat_map { |id| records_by_id[id] }
records.compact!ここで key は reflection.join_primary_key で、
単一キーなら "id" のような文字列ですが、複合キーの場合は ["shop_id", "id"] のような「カラム名の配列」になります。
belongs_to先が複合 primary key を持つ場合
(BelongsToReflection#join_primary_key→association_primary_key)has_many/has_one側が複合 foreign key を持つ場合
(AssociationReflection#join_primary_key→foreign_key)
バグの核心
複合キーの場合でも record[key] と一発で読もうとしているのが問題です。
record[["shop_id", "id"]]は、「["shop_id", "id"]という名前の属性」を読む挙動になる- 当然そんな属性は存在しないが、ここでは
MissingAttributeErrorにならず、Null属性としてnilが返る - 結果として、全レコードのグルーピングキーが
nilになり、1つのグループrecords_by_id[nil]に全部まとまる - しかし
idsは[ [1, 2], [1, 3], ... ]のような「複合キーのタプル配列」になっているので、rubyでrecords = ids.flat_map { |id| records_by_id[id] }records_by_id[[1, 2]]等を探しても何も見つからない - 結果として
recordsは[]になり、その後のcompact!でも変わらず、関連は空配列になってしまう
このため、「SQL ではちゃんとレコードが取れているのに、その後の Ruby 側でドロップされて消える」という非常に気付きづらい不具合が起きていました。
修正内容
キーが複合キー(配列)である場合に、配列の各要素カラムを個別に読み出し、同じ構造(配列)でグルーピングキーを構成するように修正しています。
変更後(擬似コード):
records_by_id = records.group_by do |record|
if key.is_a?(Array)
key.map { |column| record[column] } # ["shop_id", "id"] -> [record["shop_id"], record["id"]]
else
record[key]
end
end- 単一カラムキーのケースは従来通り
record[key]のままなので、挙動は変えない - 複合キーの場合だけ、
["shop_id", "id"]→[record["shop_id"], record["id"]]という配列キーでグルーピングするようになる - これにより、
idsに入っている複合 ID タプル(例:[1, 2])とrecords_by_idのキーが一致し、flat_mapで正しくレコードが取り出せる
再現ケースの例
テストで使われている構成は以下のようなものです(要約):
class Cpk::Author < ActiveRecord::Base # 単一カラム PK
has_many :ordered_books, -> { order(id: :desc) }, class_name: "Cpk::Book"
has_many :orders, through: :ordered_books, source: :order
has_many :no_joins_orders,
through: :ordered_books,
source: :order,
disable_joins: true
end
class Cpk::Book < ActiveRecord::Base # PK [:author_id, :id]
belongs_to :order, foreign_key: [:shop_id, :order_id] # Cpk::Order PK [:shop_id, :id]
endauthor.orders.to_a
通常の JOIN を使うパスでは、期待通り[ #<Order ...>, #<Order ...> ]が返るauthor.no_joins_orders.to_adisable_joins: trueのパスでは、main ブランチでは[]が返ってしまう
この PR の修正後は、JOIN ありの場合と同じ正しいリストが返るようになる
テスト追加
has_many_through_disable_joins_associations_test.rbにtest_ordered_disable_joins_through_with_composite_primary_key_sourceを追加- 既存の
Cpk::Author/Cpk::Book/Cpk::Orderモデルを再利用している - main ではこのテストが赤(
no_joins_ordersが[])になり、修正適用後に緑になることを確認 - sqlite3 / postgresql 両方で red→green を確認している
- 関連するテスト群 (
has_many_through_disable_joins_associations_test,has_one_through_disable_joins_associations_test,has_many_through_associations_test,has_many_associations_test) も両アダプタでグリーン
- 影響範囲・注意点
影響を受けるのは、以下すべてを満たす関連のみです。
has_many/has_one :throughを利用している- 関連定義に
disable_joins: trueを付けている - 関連チェーン中のどこかに
order(...)が指定されている
(最終スコープにorderがない → in-memory ソート経由になるパス) - 途中または最終的な関連先が複合キーを利用している
- 複合 primary key を持つ
belongs_to先 - 複合 foreign key を使う
has_many/has_oneなど
- 複合 primary key を持つ
この条件に当てはまる場合、これまで空配列が返っていたのが、実際には関連するレコードが返るようになる ため、以下に注意するとよいです。
- 既存コードが「空配列である」ことを前提としたワークアラウンドを組んでいた場合、ロジックが変わる可能性がある
(例: 期待通り動かないのでdisable_joinsを避けるようにしていたが、いつの間にか残っていた、等) - この修正により「取得結果が増える」方向に変わるので、セマンティクスとしては本来の挙動に近づきますが、テストが「空を期待していた」場合は見直しが必要です
- 単一キーの関連や、
disable_joins: false/ 未指定の関連への影響はありません - in-memory グルーピングのロジック側の変更のみで、SQL 発行やクエリパス自体は変わっていません
- 参考情報 (あれば)
- この問題は、最近修正された他の複合キー関連の不具合(
findやids=writer で配列キーの扱いを誤っていたもの)と「同じファミリー」のバグとされていますが、メカニズムは別です。- 以前の問題: 配列キーを「no-op な型キャスト」として誤用
- 今回の問題: 配列キーを「属性名」として誤用し、
record[["shop_id", "id"]]のようなアクセスをしていた
activerecord/CHANGELOG.mdにも本件が追記されており、disable_joinsと複合キーの組み合わせに関する既知の問題がこれでカバーされる形になります。
#57469 Fix FormBuilder#to_partial_path returning nil for non-Builder subclasses
マージ日: 2026/6/1 | 作成者: @55728
- 概要 (1-2文で)
FormBuilder#to_partial_pathが、クラス名が*Builderで終わらないサブクラスに対してnilを返してしまうバグを修正した PR です。String#sub!をString#subに変更することで、あらゆる FormBuilder サブクラスで常に有効なパーシャルパス文字列が得られるようになります。
- 変更内容の詳細
バグの内容
ActionView::Helpers::FormBuilder には、インスタンスをそのまま render できるように to_partial_path / _to_partial_path が定義されています。
現状の実装(バグあり):
def self._to_partial_path
@_to_partial_path ||= name.demodulize.underscore.sub!(/_builder$/, "")
endここで問題なのは sub!(破壊的メソッド)を使っている点です。
"labelled_form_builder".sub!(/_builder$/, "") # => "labelled_form"(マッチするので OK)"admin_form".sub!(/_builder$/, "") # => nil(マッチしないので nil)
sub! はマッチしなかった場合 nil を返す仕様のため、クラス名が *_builder で終わらない場合、_to_partial_path が nil になり、そのままキャッシュされてしまいます。結果として:
class AdminForm < ActionView::Helpers::FormBuilder; end
AdminForm._to_partial_path # => nil (本来は "admin_form" を期待)
AdminForm.new(...).to_partial_path # => nil
# => render @admin_form で後段が落ちるRails コアのテストにある以下のサブクラスも同様に壊れていました:
class LabelledFormBuilderSubclass < LabelledFormBuilder; end
LabelledFormBuilderSubclass.new(...).to_partial_path
# => nil(本来は "labelled_form_builder_subclass" を期待)修正内容
sub! を非破壊版の sub に変更し、マッチしない場合も元文字列がそのまま返るようにしました。
def self._to_partial_path
- @_to_partial_path ||= name.demodulize.underscore.sub!(/_builder$/, "")
+ @_to_partial_path ||= name.demodulize.underscore.sub(/_builder$/, "")
endString#sub はマッチしなければ元の文字列のコピーを返すため、*_builder で終わらない FormBuilder サブクラスでも、_to_partial_path には常に有効な文字列が入ります。
修正後の挙動:
ActionView::Helpers::FormBuilder._to_partial_path
# => "form" (従来どおり)
LabelledFormBuilder._to_partial_path
# => "labelled_form" (従来どおり。末尾 _builder が削られる)
LabelledFormBuilderSubclass._to_partial_path
# => "labelled_form_builder_subclass" (nil ではなくなる)
class AdminForm < ActionView::Helpers::FormBuilder; end
AdminForm._to_partial_path
# => "admin_form"つまり:
FormBuilder/*Builderで終わる慣習に沿ったクラス
→ これまでどおり末尾_builderが取り除かれる*Builderで終わらないクラス
→ クラス名をそのままunderscoreした文字列が返る(admin_formなど)
テストと CHANGELOG
- 既存の
test_form_for_with_labelled_builder_pathは「慣習通りの*Builder名」のケースだけを見ていた - そこに、既に定義済みの
LabelledFormBuilderSubclassを使って「*Builderで終わらないサブクラス」の回 regresstion テストを追加 actionview/test/template/form_helper_test.rb全体(343 runs / 431 assertions)はグリーンactionview/CHANGELOG.mdに、バグ修正としてエントリを追加
- 影響範囲・注意点
- 影響するのは:
ActionView::Helpers::FormBuilderを継承したクラス- かつクラス名が
*Builderで終わらないもの - そして
render @form_builder_instanceのように、to_partial_pathベースでレンダリングしているケース
- これまで暗黙に壊れていたケースが正常に動くようになる変更であり、既存の慣習 (
*Builderでクラスを命名) に従ったコードの挙動は変わりません。 _to_partial_pathのキャッシュに今までnilが入っていたケースでは、今後は「期待されるパーシャルパス文字列」が入るようになるだけなので、後方互換性の問題は実質的にありません(nilを意図的に利用していたコードがない限り)。- もし
FormBuilderサブクラスを*Builderで終わらない名前で定義しており、かつrender @builderのような使い方をしている場合、Rails 更新後に初めてそこが動くようになる可能性があります。その場合は対応するパーシャル(例:app/views/admin_form/_admin_form.html.erb)が存在するか確認してください。
- 参考情報 (あれば)
- 対象メソッド:
ActionView::Helpers::FormBuilder._to_partial_path/#to_partial_path - 関連する Ruby 仕様:
String#sub!(pattern, replacement): マッチしなければnilString#sub(pattern, replacement): マッチしなければ元文字列のコピーを返す
- バグの起点:
- もともとは 2009 年の
self.model_name実装内でsub!が使われており、その後_to_partial_pathへのリネームを経ても引き継がれていた
- もともとは 2009 年の
- 影響コンポーネント:
actionviewのみ(3 ファイルの微小変更)
#57531 Fix collection ids= writers raising RecordNotFound for composite primary key models with string ids
マージ日: 2026/6/1 | 作成者: @55728
- 概要 (1-2文で)
複合主キーを持つモデルに対してhas_many/has_and_belongs_to_manyのxxx_ids=を文字列のID配列で呼び出すと、実在するレコードでもActiveRecord::RecordNotFoundが発生していた問題を修正するPRです。
フォームやJSONから来る「文字列の複合ID」を正しくキャストして関連付けできるようにしています。
- 変更内容の詳細
問題の挙動
対象ケース:
class Author < ApplicationRecord
has_many :books # Book.primary_key = [:author_id, :id]
end
ids = author.books.ids # => [[1, 10], [1, 20]]
author.book_ids = ids # OK (整数)
author.book_ids = ids.map { |t| t.map(&:to_s) } # ❌ RecordNotFound が発生フォーム (collection_select / チェックボックス) や params、JSON では ID は通常文字列になるため、
author.book_ids = params[:book_ids] # params[:book_ids] が [["1","10"], ["1","20"]] のような文字列の配列としたときに、本来存在する Book レコードが選択されているにもかかわらず ActiveRecord::RecordNotFound が発生していました。
原因
CollectionAssociation#ids_writer のキャスト処理が、複合主キーを想定していなかったことが原因です。
該当コード(単純化):
pk_type = klass.type_for_attribute(primary_key)
ids.map! { |id| pk_type.cast(id) }単一主キー:
primary_keyは"id"のような文字列
→type_for_attribute("id")はidカラムの型 (integerなど) を返す
→cast("1") # => 1となり、文字列が期待通りに整数へキャストされる複合主キー:
primary_key/association_primary_keyは["author_id", "id"]のような「カラム名の配列」
→type_for_attribute(["author_id", "id"])は意味のある型を解決できない
→ 結果として「汎用のActiveModel::Type::Value」が返る → そのcastはほぼ no-op なので、["1", "10"]はそのまま文字列のまま残る
その後の流れ:
- DB 検索自体は
where(primary_key => ids)で行われるため、DB 側の型変換によりレコードはちゃんと取得できる。 - 取得したレコードを Ruby 側で「主キー値をキー」にハッシュ化する際、主キーは 整数タプル になる例:ruby
# 実体はこういうイメージ index = { [1, 10] => <Book ...>, [1, 20] => <Book ...>, } - ところが lookup 側は、キャストされていない
["1", "10"]などの 文字列タプル をvalues_atに渡す:rubyindex.values_at(["1", "10"], ["1", "20"]) # => どれも見つからない - その結果、期待数と取得数が一致せず「存在しないIDが指定された」と判断し
ActiveRecord::RecordNotFoundを投げる。
修正内容
複合主キーの場合に、各キー成分ごとに対応するカラム型でキャストするように CollectionAssociation#ids_writer を修正しています。
イメージとしては次のような処理に分岐します(擬似コード):
if primary_key.is_a?(Array)
# ["author_id", "id"] のような配列
column_types = primary_key.map { |key|
klass.type_for_attribute(key)
}
ids.map! do |tuple|
# tuple は ["1", "10"] のような配列
tuple.each_with_index.map { |value, i|
column_types[i].cast(value) # => [1, 10] にキャストされる
}
end
else
# 従来の単一主キーの処理
pk_type = klass.type_for_attribute(primary_key)
ids.map! { |id| pk_type.cast(id) }
endこれにより:
author.book_ids = [["1", "10"], ["1", "20"]] # も正しく動作となり、フォームやAPIから渡された文字列複合IDをそのまま xxx_ids= に渡しても問題なく関連付けできるようになります。
この処理は:
has_manyのxxx_ids=has_and_belongs_to_manyのxxx_ids=
の両方で共有されているため、HABTM も同時に修正対象になっています。
テスト
has_many_associations_test.rb に回帰テストが追加されています:
- 複合主キーの関連に対して
- 文字列の複合IDを
xxx_ids=に渡す - 正しく関連づけされ、例外が発生しないことを検証
このテストは main ブランチでは red(バグ再現)、本修正適用後は green になることが確認されています。
sqlite3 / postgresql の両DBアダプタで has_many / has_and_belongs_to_many 関連テスト一式が通過しています。
- 影響範囲・注意点
- 影響を受けるのは「複合主キーを使っているモデル」の
has_many/has_and_belongs_to_manyのxxx_ids=のみです。 - 単一主キーのモデルに対する
xxx_ids=の挙動・互換性には変更はありません(既存のキャストロジックをそのまま維持)。 - 実運用上の影響:
- フォームのチェックボックスや
collection_selectで複合主キーの関連を選択して params 経由で更新するケース - API / JSON で文字列の複合IDを受け取り、そのまま
xxx_ids=に突っ込むケース
これらが RecordNotFound で落ちていた場合に、正常に動作するようになります。
- フォームのチェックボックスや
has_and_belongs_to_manyでも同じids_writerを共有しているため、HABTM で複合主キーを用いている場合も自動的に修正が効きます。- アプリ側で
xxx_ids=を monkey patch している場合:- 内部実装に依存したコードを書いていると、今回の内部仕様変更と競合する可能性があるため確認推奨です。
- 参考情報 (あれば)
- このPRは、以下と同じクラスの不具合(
primary_keyが配列であることを考慮せずtype_for_attributeしてしまうケース)の一つです:find_by_token_forの修正: コミット8617a7cfind_signedの修正: PR #57245findに対する兄弟修正: PR #57530
- 変更ファイル:
activerecord/CHANGELOG.md: バグ修正の記録を追加activerecord/lib/active_record/associations/collection_association.rb:ids_writerの複合主キー対応activerecord/test/cases/associations/has_many_associations_test.rb: 回帰テスト追加
#57530 Fix find silently returning [] for composite primary key ids passed as strings
マージ日: 2026/6/1 | 作成者: @55728
- 概要 (1-2文で)
Rails の複合主キーを持つモデルに対し、findに「文字列の id タプル」を複数渡した場合にレコードが1件も返らず常に[]になっていた不具合を修正する PR です。各主キー列ごとに適切な型キャストを行うことで、単一主キーと同様にパラメータ由来の文字列 id でも正しくレコードが取得されるようになります。
- 変更内容の詳細
問題となっていた挙動
対象は「複合主キー+find に複数 id タプルを渡す」ケースです。
class Book < ApplicationRecord
self.primary_key = [:author_id, :id]
end
# これは動く (整数タプル)
Book.find([[1, 10], [1, 20]]) # => [#<Book...>, #<Book...>]
# これがバグっていた (文字列タプル)
Book.find([["1", "10"], ["1", "20"]]) # => [] # 本来は 2 件返ってほしい単一主キーの場合は以前から Model.find("1") などの文字列 id を自動で型キャストしており問題ありませんでしたが、複合主キーで「2件以上の id タプル」を渡した場合だけ、例外も出さずに空配列を返すという非常に発見しづらい不具合になっていました。
バグの原因
内部的には FinderMethods#find_some_ordered が次の手順で動きます:
where(primary_key => ids)で DB から行を取得- ここでは DB 側が文字列
"1"を整数 1 に暗黙キャストするため、行自体は正しくヒットしている。
- ここでは DB 側が文字列
取得した結果を
result.in_order_of(:id, casted_ids)で指定した id の順序に並び替え・フィルタcasted_idsの生成に次のコードを使っていた:rubyids.map { |id| model.type_for_attribute(primary_key).cast(id) }
ここでの問題点は、複合主キーの primary_key が配列 ["author_id", "id"] であることです。
model.type_for_attribute(primary_key)に「配列のカラム名」を渡してしまう- ActiveRecord は配列名をカラムとして解決できず、フォールバックとして「汎用型
ActiveModel::Type::Value」を返す - この型の
castは基本的に 何もしない (no-op) ので、["1", "10"]はそのまま文字列のまま残る
- ActiveRecord は配列名をカラムとして解決できず、フォールバックとして「汎用型
結果として:
result側のレコードは DB の暗黙キャストにより[1, 10]のように 整数タプル- 並び替えに使う
series側は["1", "10"]のように 文字列タプル
Enumerable#in_order_of(:id, series) は (record.id == series_item) という形で一致比較を行いますが、[1, 10] == ["1", "10"] は false になるため、すべてのレコードがマッチせず除外され、結果が [] になっていたという流れです。
修正内容
find_some_ordered で使う主キーキャスト処理を、複合主キーを正しく扱えるように変更しています。
新たに導入されたメソッドのイメージは以下のようなものです(説明用。実際の定義名は PR を参照):
def cast_primary_key(id)
if model.composite_primary_key?
# 複合主キー: 各列ごとに型キャスト
primary_key.zip(id).map do |attr, value|
model.type_for_attribute(attr).cast(value)
end
else
# 従来挙動: 単一主キーはそのまま
model.type_for_attribute(primary_key).cast(id)
end
endポイント:
複合主キーの場合
primary_keyは[:author_id, :id]のような配列- 渡される
idは["1", "10"]のような配列 primary_key.zip(id)で[[:author_id, "1"], [:id, "10"]]の形にし、 各カラム名ごとにtype_for_attribute(attr).cast(value)を実行- 結果として
[1, 10]のように 正しい型にキャストされたタプル が得られる
単一主キーの場合
- 既存コードとバイトレベルで同じ処理を残しており、挙動変更は一切なし
これにより、in_order_of に渡される series 側も [1, 10] のように整数タプルとなり、record.id == series_item が正しく true になって、期待通りのレコード配列が返ってくるようになります。
テスト
activerecord/test/cases/finder_test.rbに、文字列の複合 id をfindに渡す回帰テストを追加- そのテストが:
- 現行
mainでは Red (バグ再現) - この修正を含むブランチでは Green
であることを、sqlite3 / PostgreSQL 両方で確認した旨が PR に記載されています。
- 現行
CHANGELOG にもこの不具合修正が追記されています。
- 影響範囲・注意点
影響を受ける条件
このバグの影響を受けるのは、次の条件が揃う場合です:
- モデルが 複合主キー (
self.primary_key = [:col1, :col2, ...]) を使っている findに 2件以上の id タプル を渡す- 例:
Book.find([["1", "10"], ["1", "20"]]) - 1件だけだと
find_one経由になり、このパスは元々問題なかった
- 例:
- 関連に 明示的な
orderを付けていない- デフォルトの
find_some_ordered経路を通る場合のみ - 逆に
order(...).find([...])のようにしていたケースは、内部で別経路を辿るため元から影響を受けていなかった
- デフォルトの
- 渡す id が DBカラムのネイティブ型と異なり、キャストが必要な値 である
- 代表例: コントローラの
params由来の文字列"1","10" - すでに整数タプル
[1, 10]にしてから渡していた場合は、以前から問題なく動作していた
- 代表例: コントローラの
実務的な注意点・確認ポイント
Rails 7.1 以降で複合主キーを使っている場合は、次のようなコードが潜んでいないか注意するとよいです:
ruby# 典型的な危険パターン(修正前は常に [] が返りうる) def index books = Book.find(params[:ids]) # params[:ids] が [["1","10"], ["1","20"]] のような形 # books が [] のまま後段ロジックに渡されると、不具合が表面化しにくい endこの PR 適用後は、上記のようなコードでも 期待通りレコードが返る ようになります。
逆に、これまで「
[]が返ることを前提にロジックを書いてしまっていた」場合は、修正適用後に挙動が変わる可能性があります。- 本来それはバグ依存の実装なので望ましくはありませんが、アップグレード時にはテストで挙動の変化がないか確認が必要です。
単一主キーの
Model.find("1")の挙動は一切変わりません。すでに整数にキャストしてから
findしていたコードも挙動は変わりません。
- 参考情報 (あれば)
- この種の「複合主キー+キャストまわりの取りこぼし」は過去にもいくつか修正されています:
find_by_token_forまわりの修正: コミット8617a7cfind_signedの修正: PR #57245
- 今回の問題は
in_order_of導入時 (9cb09411e1, 2021年) に仕込まれており、複合主キー機能(Rails 7.1)リリースによって初めて表に出てきた、という経緯があります。 - 該当コードは
find_byや Predicate Builder を通らず、既に取得済みのレコード配列に対して順序付けだけを行う経路なので、1カラムごとのtype_for_attribute(column).cast(value)を素直に使うのが最もシンプルかつ一貫した修正になっています。
#57439 Fix PostgreSQL range column schema dump producing invalid Ruby
マージ日: 2026/6/1 | 作成者: @55728
- 概要 (1-2文で)
PostgreSQL の range 型カラム(daterange,tsrange,tstzrangeなど)にデフォルト値がある場合、db:schema:dumpが Ruby としてパース不能なschema.rbを生成していた不具合を修正する PR です。range の両端値をサブタイプ(Date / Time など)のtype_cast_for_schemaを通して Ruby リテラルとして正しく出力するように変更されています。
- 変更内容の詳細
これまでの問題
OID::Range#type_cast_for_schema は、range 型のデフォルト値を schema.rb に書き出す際に、単純に Range#inspect に丸投げしていました:
def type_cast_for_schema(value)
value.inspect.gsub("Infinity", "::Float::INFINITY")
endRange#inspect は両端の値に対して inspect を呼ぶため、以下のような表現になります。
(Date.new(2024, 1, 1)...Date.new(2025, 1, 1)).inspect
# => "Mon, 01 Jan 2024...Wed, 01 Jan 2025"
(Time.utc(2024, 1, 1)...Time.utc(2025, 1, 1)).inspect
# => "2024-01-01 00:00:00 UTC...2025-01-01 00:00:00 UTC"これは人間には読みやすいものの、Ruby の式としては無効です(クォートされていない文字列扱いになる)。
結果として、例えば以下のマイグレーション:
create_table :events do |t|
t.daterange :period, default: "[2024-01-01,2025-01-01)"
endから db:schema:dump した schema.rb には
t.daterange "period", default: Mon, 01 Jan 2024...Wed, 01 Jan 2025のような行が出力され、db:schema:load / db:setup 時に SyntaxError で落ちていました。
int4range / numrange など数値系の range は整数 / Float の inspect がそのまま Ruby リテラルになるため問題は出ておらず、日付・時刻系サブタイプだけが壊れていた状況です。
新しい実装
range の両端を、サブタイプの type_cast_for_schema を通して Ruby リテラルに変換し、それらを range 演算子(.. / ...)でつなぎ直すように変更されました。
変更後のメインロジック:
def type_cast_for_schema(value)
from = bound_for_schema(value.begin)
to = bound_for_schema(value.end)
op = value.exclude_end? ? "..." : ".."
"#{from}#{op}#{to}"
end
private
def bound_for_schema(bound)
case bound
when nil
"nil"
when ::Float::INFINITY
"::Float::INFINITY"
when -::Float::INFINITY
"-::Float::INFINITY"
else
@subtype.type_cast_for_schema(bound)
end
endポイント:
@subtypeはOID::Rangeに紐づくサブタイプ(OID::Integer,OID::Float,OID::Date,OID::Timestamp,OID::TimestampWithTimeZoneなど)。- Date / Time 系の subtype ではもともと
type_cast_for_schemaがvalue.to_fs(:db).inspectのように DB 形式の文字列 +inspect(= クォート付き文字列) を返すため、
例えばdaterangeは"2024-01-01"..."2025-01-01"のような Ruby 文字列リテラルで表現されるようになる。 - 範囲の両端が以下の値のときは特別扱い:
nil(beginless / endless) →"nil"::Float::INFINITY→"::Float::INFINITY"-::Float::INFINITY→"-::Float::INFINITY"
これにより、schema.rb には次のように出力されます。
| Range (概念的な値) | 修正前 (壊れている例含む) | 修正後 (Ruby として正しい) |
|---|---|---|
1...10 (int4range) | 1...10 | 1...10(変更なし) |
1.5..2.5 (numrange) | 1.5..2.5 | 1.5..2.5(変更なし) |
(-Float::INFINITY..Float::INFINITY) (numrange) | -::Float::INFINITY..::Float::INFINITY | -::Float::INFINITY..::Float::INFINITY(変更なし) |
Date.new(2024,1,1)...Date.new(2025,1,1) (daterange) | Mon, 01 Jan 2024...Wed, 01 Jan 2025 ❌ | "2024-01-01"..."2025-01-01" ✅ |
Time.utc(2024,1,1)...Time.utc(2025,1,1) (tsrange) | 2024-01-01 00:00:00 UTC...2025-01-01 00:00:00 UTC ❌ | "2024-01-01 00:00:00"..."2025-01-01 00:00:00" ✅ |
nil..10 (beginless) | ..10(Ruby 的には有効) | nil..10(より明示的) |
1..nil (endless) | 1..(Ruby 的には有効) | 1..nil(より明示的) |
beginless / endless の表現は以前も Ruby として有効でしたが、nil を明示する形に変わっています(動作的にはほぼ等価ですが、より自己説明的なコードになります)。
互換性・ラウンドトリップ
schema.rb の "2024-01-01"..."2025-01-01" のような文字列 range リテラルは、
- Ruby の評価時には
Range<String>として解釈される - その後
OID::Range#serialize→subtype.cast→subtype.serializeを通して、OID::Date/OID::Timestamp/OID::TimestampWithTimeZoneが適切に Date / Time オブジェクトに変換
というフローを通るため、DB デフォルトとのラウンドトリップ(DB → schema dump → Ruby で評価 → serialize → DB)は動作検証済みです。
テストとしては:
OID::Range#type_cast_for_schemaの各サブタイプに対し、生成されたリテラルがRubyVM::InstructionSequence.compileで正しくコンパイルできることを確認- PostgreSQL 17 を使った end-to-end での round-trip 検証(
int4range,daterange,tsrange,tstzrange)
が追加されています。
- 影響範囲・注意点
- 直接影響を受けるケース
- PostgreSQL の range 型 (
daterange,tsrange,tstzrange) で「デフォルト値」を設定しているプロジェクト。 - これらのカラムがある状態で
db:schema:dump→db:schema:load/db:setupを実行するとSyntaxErrorが出ていたケースが解消されます。
- PostgreSQL の range 型 (
- 影響しない/ほぼ影響しないケース
- range カラムにデフォルト値を設定していない場合: 今回の不具合自体が発生していないため、挙動は変わりません。
int4range/numrangeなど数値系 range の場合: 以前から Ruby リテラルとして正しい文字列が出ており、今回も等価な出力を維持しています(Infinity 表現も同一)。
- 微妙な挙動変化
- beginless / endless range の schema 表現が
..10/1..からnil..10/1..nilに変わりますが、Ruby としてはどちらも有効で、range の意味もほぼ同じです(nil自体はサブタイプでキャストされる段階で無限側として処理される)。
- beginless / endless range の schema 表現が
- 移行時の注意
- 既存の
schema.rbを手で編集して回避していた場合(例: 自前でクォートを追加していたなど)、Rails を更新してからdb:schema:dumpし直すと、より機械的な"YYYY-MM-DD"形式に揃えられるため、diff が大きめに出る可能性はあります。 - schema から再生成された range のデフォルト値が、DB 側に設定されているものと一致しているか、気になる場合は一度
db:schema:load→ 実テーブル定義の確認をすると安心です。
- 既存の
- 参考情報 (あれば)
- 対象クラス:
ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range
- 関連ファイル:
activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rbactiverecord/test/cases/adapters/postgresql/range_test.rbactiverecord/CHANGELOG.md(今回の挙動変更が記載)
- 不具合の性質:
- range 型サブタイプ(特に Date / Time 系)の
inspectに依存していたために起きた、「人間可読だが Ruby として無効」な schema 出力が原因で、db:schema:load時に即座に発覚するタイプのバグです。 - 今回の修正で、range も他の型(
date,timestampなど)と同様に、サブタイプ自身のtype_cast_for_schemaを尊重する設計に揃えられました。
- range 型サブタイプ(特に Date / Time 系)の
#57403 Update Active Storage for ImageProcessing 2.0
マージ日: 2026/6/1 | 作成者: @janko
- 概要 (1-2文で)
ImageProcessing 2.0 のリリースに合わせて、Active Storage の依存関係・設定・ドキュメントを更新した PRです。v2.0 での破壊的変更(明示的な gem 依存と libvips による「危険フォーマット」のブロック)に追随しています。
- 変更内容の詳細
2-1. ImageProcessing の利用方法の更新
ポイント:
ImageProcessing 2.0 からは、内部で mini_magick や ruby-vips を自動ロードしなくなったため、アプリケーション側で明示的に Gem を追加する必要があります。
このPRで行っている主な変更:
- Gemfile / アプリケーションジェネレータの更新
rails new時に生成されるGemfileテンプレート (Gemfile.tt) が更新され、ImageProcessing を使う場合に必要な設定が反映されるようになりました。- 実際の Gemfile にも ImageProcessing 2.0 系向けの記述が反映されています。
今後推奨される構成イメージ(例):
# Gemfile
gem "image_processing", "~> 2.0"
# 利用するバックエンドを明示的に指定 (どちらか/両方)
gem "mini_magick" # ImageMagick ベース
gem "ruby-vips" # libvips ベースActive Storage の ImageProcessing ベースの variant 変換は、上記のようにサポートライブラリを別途入れておく前提で動作するようになります。
2-2. LoadError への対応強化
ImageProcessing 2.0 では、mini_magick や ruby-vips が Gemfile に無い場合、ロード時に LoadError が発生しやすくなります。
この PR では:
- Active Storage の ImageProcessing 関連コード(
image_processing_transformerなど)において、mini_magick・ruby-vipsがロードできない場合のLoadError分岐を追加。 - これにより、対象の gem が入っていない状態で variant 処理をしようとしたときに、より意図した形で例外ハンドリングができるようになります(Rails 側で明示的に扱える)。
コードイメージ(概念的な例):
begin
require "ruby-vips"
rescue LoadError
# 適切なエラーメッセージや fallback を提供できるような分岐
end2-3. libvips 8.13+ における「危険フォーマット」ブロックへの対応
背景:
- libvips 8.13+ では、既定で「untrusted / unfuzzed loader」とされるフォーマット(安全性が十分でないフォーマット)をブロックするようになりました。
- これには BMP, PSD, ICO などが含まれます。
- 一方で、Active Storage 側ではこれらを「variable content types(バリアント生成を許可するコンテンツタイプ)」として受け付けています。
この PR での対応:
activestorage/lib/active_storage/transformers/vips.rbに変更が入り、libvips 側の設定により特定フォーマットがブロックされる可能性を意識した実装になっています。- PR 本文にもある通り、「Active Storage 上では許可されているが libvips 側ではブロックされるフォーマット」をどう扱うのがベストかはまだ検討中のトピックとして明示されています。
開発者としては、BMP/PSD/ICO の変換が vips バックエンドで突然失敗する可能性がある点を意識する必要があります。
2-4. Active Storage エンジン・モデル周りの調整
activestorage/lib/active_storage/engine.rb- Active Storage エンジンの初期化処理の中で、ImageProcessing / vips / mini_magick のロードとエラーハンドリングロジックが更新されています。
- Rails 起動時に、利用可能な画像処理バックエンドがどうあるべきかをより明確に扱うようになっています。
activestorage/app/models/active_storage/variant.rb- ImageProcessing 2.0 を前提とした微調整(呼び出し側のパラメータやデフォルト挙動の整合)が行われています。
activestorage/lib/active_storage/transformers/image_processing_transformer.rb- ImageProcessing への委譲コードを 2.0 に合わせて修正。
2-5. ドキュメント・CHANGELOG・テストの更新
activestorage/CHANGELOG.md- ImageProcessing 2.0 対応に関する変更点を明記。
guides/source/active_storage_overview.md- ImageProcessing のセットアップ方法(Gemfile に
ruby-vipsやmini_magickを明示的に追加する必要があることなど)をガイドに追記。
- ImageProcessing のセットアップ方法(Gemfile に
railties/test/...- 新しい Gemfile テンプレート・Active Storage 設定に即したテストへ更新(
engine_integration_test.rb,app_generator_test.rb)。
- 新しい Gemfile テンプレート・Active Storage 設定に即したテストへ更新(
- 影響範囲・注意点
影響範囲:
- Active Storage で ImageProcessing を使って画像バリアントを生成しているすべてのアプリケーション。
- 特に、以下のようなアプリは影響が大きいです:
image_processingだけ Gemfile に書いており、mini_magickやruby-vipsを明示的に追加していない- vips ベースで BMP/PSD/ICO などを扱っている
注意点・必要な対応:
Gemfile の見直し
- 画像処理バックエンドを明示的に追加してください:ruby
gem "image_processing", "~> 2.0" # 使用するバックエンドに応じて gem "mini_magick" # ImageMagick を利用するなら gem "ruby-vips" # libvips を利用するなら - どちらも入れておくことも可能ですが、その場合どちらを使うかは Active Storage 設定やコード側の指定に依存します。
- 画像処理バックエンドを明示的に追加してください:
BMP/PSD/ICO 等の扱い
- libvips 8.13+ 環境で
ruby-vipsを使っている場合、これらのフォーマットは標準設定だとブロックされる可能性があります。 - 対策案:
- そもそもこれらのフォーマットでバリアント(リサイズ・サムネイル)を作らないようにする
- ImageProcessing のバックエンドに
mini_magickを使うように切り替える(ImageMagick 側の設定に依存) - libvips の設定を調整して対象フォーマットを許可する(ただしセキュリティリスクを十分検討する必要あり)
- libvips 8.13+ 環境で
例外ハンドリング
LoadErrorに対して Active Storage 側でハンドリングが追加されたとはいえ、 実運用では「必要な Gem が入っていない」「危険フォーマットで vips が失敗する」ケースに対する アプリ側のエラーメッセージやフォールバック処理を検討するのが望ましいです。
- 参考情報 (あれば)
ImageProcessing 2.0 CHANGELOG
https://github.com/janko/image_processing/blob/master/CHANGELOG.md#200-2026-05-20libvips 8.13 の「untrusted formats」について
https://www.libvips.org/2022/05/28/What's-new-in-8.13.html#blocking-of-unfuzzed-loadersActive Storage Overview (Rails Guides, 該当部分がこの PR で更新)
https://guides.rubyonrails.org/active_storage_overview.html
#57004 Reimplement RedisCache store using redis-client
マージ日: 2026/6/1 | 作成者: @byroot
- 概要 (1-2文で)
Redis ベースのキャッシュストアRedisCacheStoreを、従来のredisgem ではなく新しいredis-clientを使って作り直した PRです。既存の設定・APIとの互換性を確保するため、旧実装はDeprecatedRedisCacheStoreとして残しつつ、RESP2 プロトコルを使うことで Redis サーバの要件は従来から変えない形になっています。
- 変更内容の詳細
全体方針
ActiveSupport::Cache::RedisCacheStoreの内部実装をredisgem 依存からredis-client依存に差し替え。redis:オプションなど、これまでの設定インターフェイスを壊さないために、旧実装をActiveSupport::Cache::DeprecatedRedisCacheStoreとして分離・温存。redis-clientは RESP2 を使うように設定されており、Redis サーバ側のバージョン要件を増やさない設計。
主なコードレベルの変更点
1) 新しい RedisCacheStore の実装切り替え
activesupport/lib/active_support/cache/redis_cache_store.rb が大きく書き換えられています。
ポイント:
- 依存ライブラリが
redis→redis-clientに変更。 - シャーディング(従来の
Redis::Distributed相当)について、redis-client側にRedisClient.ringを実装して対応。- これにより、複数 Redis インスタンスへの分散キャッシュも継続してサポート。
- 基本的なキャッシュ API (
read,write,fetch,delete,increment,decrement, マルチキー操作など) の挙動は、可能な限り従来と互換になるよう維持。
使用イメージ(概念的には従来と同じ):
Rails.application.config.cache_store = :redis_cache_store, {
url: "redis://localhost:6379/0",
# 他オプションも基本同じ感覚で利用可能
}複数ノードへの分散(リング)も、内部では RedisClient.ring によって実現される想定です。
2) 旧実装を DeprecatedRedisCacheStore として退避
activesupport/lib/active_support/cache/deprecated_redis_cache_store.rb が新規追加され、約 500 行強の旧実装が移植されています。
- 役割:
- 以前の
redisgem ベース実装を名前を変えて保持。 redis:オプション等で直接 Redis クライアントインスタンスを渡しているような「高度/裏技的」なユースケースへの移行期間を確保。
- 以前の
- 実質的には、
- 旧
ActiveSupport::Cache::RedisCacheStore= 現ActiveSupport::Cache::DeprecatedRedisCacheStore - 新
ActiveSupport::Cache::RedisCacheStore=redis-clientベースの再実装 という状態。
- 旧
3) redis: オプションの扱い
PR 説明にもある通り、当初は :redis コンストラクタ引数を削除しようとしたものの、Redis Cluster クライアントなどを直接渡すユーザーの存在を考慮して、完全な削除は見送られています。
- つまり、「
redis:を使ってクライアントを直渡しするパターン」を完全に壊さないように配慮した設計。 - 実際のマイグレーションパスとしては、
DeprecatedRedisCacheStoreを利用する/移行期間を設ける形と思われる(詳細は今後のガイドや CHANGELOG で明示される見込み)。
4) Gemfile の更新
Gemfile/Gemfile.lockにredis-clientが追加され、redisからの移行が反映されています。- 依存関係の更新により、Rails 自身が
redis-clientを公式・標準の Redis クライアントとして扱う流れが明確になった形です。
5) テストの分離と更新
activesupport/test/cache/stores/redis_cache_store_test.rbがredis-clientベース実装向けに大きく変更。activesupport/test/cache/stores/deprecated_redis_cache_store_test.rbが新規に追加され、旧実装専用のテストとして 600 行超が移植。- 振る舞い共通部分のテスト (
cache_instrumentation_behavior,failure_raising_behaviorなど) は、新旧両ストアで動くように微修正。
これにより、
- 新実装の互換性保証(従来テストが落ちないか)
- 旧実装の「後方互換」の担保 の両方を CI で継続的に確認できるようになっています。
6) ドキュメント更新
activesupport/CHANGELOG.mdに今回の変更が追加され、RedisCacheStoreがredis-clientベースになったこと、旧実装がDeprecatedRedisCacheStoreとして残ることなどが明記されているはずです。
- 影響範囲・注意点
影響範囲
Rails のデフォルト/推奨 Redis キャッシュクライアントが
redis→redis-clientに変更- キャッシュ用途で Redis を使っている Rails アプリ全般に影響。
- ただし、基本 API やサーバ要件(RESP2)は変えていないため、ほとんどのアプリは「内部実装が変わるだけ」で済む想定。
redis:オプションでクライアントインスタンスを直接渡しているケース- 旧来の
Redisクライアント(redisgem のRedisオブジェクトやRedis::Clusterなど)を直接注入しているコードは要注意。 - そうしたケースのために
DeprecatedRedisCacheStoreが用意されているが、- 将来的には廃止される可能性が高い
- どのタイミングで何が deprecated になるかは CHANGELOG / ガイドを要確認。
- 旧来の
シャーディング/分散構成 (
Redis::Distributed相当)- 複数 Redis インスタンスに分散してキャッシュする構成は、
RedisClient.ringで実装される。 - キー分散アルゴリズムやフェイルオーバー挙動など、
Redis::Distributed依存ロジックを書いていた場合は、細かい差異がないかを確認したほうがよい。
- 複数 Redis インスタンスに分散してキャッシュする構成は、
開発者視点での注意点・確認ポイント
- Rails をアップグレードしたら、まず Redis キャッシュが正常動作しているか確認
fetch,write,delete,increment/decrement, マルチキー操作など、アプリが多用している操作を一通り叩いてみる。
- もし直接 Redis クライアントを触っているコードがあれば要チェック
- 例:
Rails.cache.redisを前提にRedisのメソッドを呼び出すようなコード。 RedisCacheStoreの内部実装がredis-clientに変わったことで、返されるオブジェクトや利用を想定していないメソッド呼び出しが壊れる可能性がある。
- 例:
- Redis Cluster / Sentinel / シャーディング構成を利用している場合
- 接続設定の記述方法・サポート状況が
redis-clientでどうなっているかを確認(redis-clientの README / Rails ガイドを参照)。 - 特にクラスタリング特有のコマンド (
READONLY,ASK,MOVEDハンドリングなど) を期待していた場合は差分に注意。
- 接続設定の記述方法・サポート状況が
- 参考情報 (あれば)
redis-client本体のドキュメント
https://github.com/redis-rb/redis-client旧
redisgem
https://github.com/redis/redis-rbRails ガイド(キャッシュストア / Redis)
まだ反映前の可能性がありますが、今後ここにredis-clientベースの記述が追加される想定です:
https://guides.rubyonrails.org/caching_with_rails.htmlこの PR の CHANGELOG エントリ
activesupport/CHANGELOG.md内のRedisCacheStore/redis-clientに関する節を参照すると、公式にサポートされる移行パス・非推奨事項が見やすくまとめられています。
#57533 Reimplement Action Cable redis adapter with redis-client
マージ日: 2026/6/1 | 作成者: @byroot
- 概要 (1-2文で)
Action Cable の Redis アダプタ実装を、従来のredisgem ベースからredis-clientベースに書き換えた PRです。これにより、Rails が巨大なredisgem へ依存せずに済むようになり、購読(Pub/Sub)まわりの実装もシンプルになっています。
- 変更内容の詳細
※PR本文・diff から読み取れる範囲に基づく解説です(ファイルパスと行数規模からの構造的推測も含みます)。
a. Redis アダプタの内部実装を redis-client に差し替え
対象ファイル:
actioncable/lib/action_cable/subscription_adapter/redis.rb(+39 / -85)
このファイルは Action Cable の Redis ベースの Subscription Adapter 実装です。ここで、以下のような切り替えが行われています。
以前:
require "redis"などでredisgem を直接利用Redis.new,subscribe,psubscribe,unsubscribeなど、redis gem 固有の API を利用して Pub/Sub を実装- コネクションプール周りも
redisgem のオブジェクトを前提とした作り
変更後:
redis-clientを用いた接続確立・購読・メッセージ受信ロジックに置き換えredis-clientの Subscription API(ブロッキングな購読ループやコールバック)に沿った実装に再構成redisgem に依存していた箇所(エラークラスや返り値の型など)をredis-clientのインターフェイスに対応するよう修正
redis-client は Redis 7 系を意識した軽量クライアントで、Pub/Sub 用の API が redis gem よりもシンプルかつストレートな設計になっており、Action Cable 側のコードもそれに合わせて大きく簡素化されています(行数が 85 行削除 / 39 行追加という差分になっているのは、その簡素化の結果と考えられます)。
b. テストコードの更新
対象ファイル:
actioncable/test/subscription_adapter/redis_test.rb(+11 / -9)
Action Cable の Redis アダプタ向けテストが、redis gem 前提の挙動から redis-client 前提に変更されています。
具体的には、例えば:
- 接続確立時のオブジェクト型やエラークラスの変更
- Pub/Sub の購読開始・停止、再接続動作などのテストケースが新しい API を使うように微調整
redis-client由来のタイミング/スレッド挙動の差異を考慮したアサーションの調整
が入っていると考えられます。テスト全体としては大きく増減しておらず (+11 / -9)、主にインターフェイス差分の吸収といったレベルの修正です。
c. #57004 との組み合わせによる依存関係変更
PR 本文で、以下のように言及されています:
Combined with https://github.com/rails/rails/pull/57004 this would allow to not longer depend on the much larger
redisgem
つまり、この PR 単体ではなく、#57004 とセットで見ると:
- Rails 全体として
redisgem を Gem dependency から外せる(または optional にできる) - Redis 関連の機能はすべて
redis-clientでまかなえるようになる
という整理が進んでいることがわかります。
#57004 側では、おそらく他の Redis 利用箇所(キャッシュストアや Action Cable 以外のアダプタ等)が redis-client に置き換えられているか、あるいは共通接続ロジックが導入されているはずです。
- 影響範囲・注意点
a. Rails ユーザ側(アプリケーション開発者)の影響
1) Gem 依存の変化
- Action Cable で Redis を使う場合、今後は
redis-clientが前提になります。 - これにより:
redisgem をわざわざ Gemfile に入れなくてもよくなる(将来的には削除推奨)- 逆に、
redis-clientが必要(Rails が依存として引き込む形になる想定)
アプリ側が Action Cable 用に redis gem のカスタム設定などをしていた場合、それらは無効になり、redis-client の設定方法へ移行する必要があります。
2) 設定値・接続オプションの見直し
redis gem と redis-client では、細かい接続オプション名や挙動が違う場合があります。たとえば:
- タイムアウト系オプション名
- コネクションプールの扱い
- reconnect/retry 動作のデフォルト
など。Action Cable の Redis アダプタ設定(config/cable.yml の url, channel_prefix, redis オプションなど)で、redis 特有のパラメータを渡している場合は、そのままでは効かなくなる可能性があります。
ただし、この PR は主に内部実装の差し替えなので、高レベルな設定インターフェイス(url や channel_prefix など)はできるだけ後方互換になっていると想定されます。特殊な設定をしていない典型的なアプリでは、そのまま動く可能性が高いです。
3) 例外クラス・ログ出力などの変更
- Redis 関連の例外クラスが
Redis::BaseErrorなどからRedisClient::Error的なものに変わる可能性があります。- 例外クラス名で rescue している場合は影響が出ます。
- 接続/購読のログメッセージ、警告メッセージが変わる可能性があります(監視やログ解析している場合は要確認)。
b. ライブラリ作者・メンテナの影響
Action Cable の Redis アダプタをラップしたり、拡張している gem・ライブラリは、redis gem に強く結合している部分があれば修正が必要になります。
- 具体例:
ActionCable::SubscriptionAdapter::Redisのインスタンスに対してinstance_variable_getなどで内部のRedisインスタンスを参照しているRedisの接続オブジェクトを前提とした monkey patch を当てている- Redis アダプタの初期化時に
Redis.currentなどの仕組みと連携させている
このような場合は、redis-client のクラスや API にあわせて実装を見直す必要があります。
c. 運用面の注意
redis-clientは比較的新しめのクライアントなので、運用中の Redis バージョンとの互換性を事前に確認することが推奨されます(特に古い Redis との組み合わせ)。- 接続数・サブスクライバ数が多い大規模環境では、
redisgem とredis-clientのパフォーマンス・メモリ使用量・再接続挙動の違いが出る可能性があります。- 本番切り替え前にステージング環境で負荷テスト・フェイルオーバーテストをしておくと安全です。
- 参考情報 (あれば)
- この PR:
- 言及されている関連 PR (#57004):
redis-clientの GitHub:- 旧
redisgem:
Action Cable の Redis アダプタまわりをカスタマイズしている場合や、Redis 連携を独自にラップしている場合は、上記 PR と redis-client の README をあわせて確認しつつ、移行方針を検討するのが良いです。
#57528 Document ports can be added to hosts in `config.hosts' [ci-skip]
マージ日: 2026/6/1 | 作成者: @p8
概要 (1-2文で)
このPRは、Rails のconfig.hostsにホスト名と一緒に「ポート番号も指定できる」ことをガイドに追記した、ドキュメント専用の変更です。コードの挙動自体は変わらず、既存機能の説明を補完するものです。変更内容の詳細 (サンプルコード含む)
変更ファイルは
guides/source/configuring.mdのみで、Rails アプリケーション設定ガイドの「config.hostsの説明」に数行の記述が追加されています。追加された内容はだいたい以下のような趣旨です(擬似的な抜粋・イメージ):
md### config.hosts # 中略: もともとの説明(許可ホストの設定など) ホスト名だけでなく、ポート番号を含めて指定することもできます: ```ruby # 例: 特定ポートのみ許可する config.hosts << "example.com:3000" config.hosts << "localhost:3001"これは、同じホスト名でもポートごとにアクセス可否を分けたい場合に有用です。
もともと Rails 本体は
"example.com:3000"のような文字列をホストとして扱うことができており(ActionDispatch::HostAuthorizationのマッチ時にホストヘッダ全体を評価)、このPRでは「その事実が公式ドキュメントに明記された」という位置付けです。[ci-skip]がタイトルに付いている通り、テストを伴わない単なるドキュメント変更として扱われています。
- 影響範囲・注意点
- 影響範囲:
- ランタイムの挙動変更や API 変更はありません。
config.hostsの既存仕様のうち、「ポートを含められる」という点がドキュメントで明文化されたのみです。 - これにより、「ホスト名だけしか指定できない」と誤解していた開発者が、ポート単位でのホワイトリスト制御を正しく設定できるようになります。
- ランタイムの挙動変更や API 変更はありません。
- 注意点:
- 実際の
Hostヘッダの値(および Rack/サーバがアプリに渡すrequest.host_with_portなど)と、config.hostsに書いた値の形式を合わせる必要があります。- 例: 開発で
http://localhost:3000にアクセスするなら、config.hosts << "localhost:3000"のようにポート付きで書く。
- 例: 開発で
- 逆に、ポートを省いた
"example.com"を設定した場合は、「任意のポートでのexample.comへのアクセスを許可する」という扱いになる実装/バージョンもあるため、厳密にポートを絞りたい場合は必ずポート込みで指定することが推奨されます。 - 一部のプロキシやコンテナ環境では、アプリケーション側から見える
Hostヘッダのポートが、クライアントからのポートと異なることがあるため、実環境でどの値が来るかをログ等で確認した上でconfig.hostsを設定するのが安全です。
- 実際の
- 参考情報 (あれば)
- 対応する機能:
ActionDispatch::HostAuthorizationミドルウェア (config.hosts設定に基づいてリクエストを許可/拒否する機能) - 関連ドキュメント(英語版・最新版を参照するとよい部分):
- Rails Guides: Configuring Rails Applications – 「
config.hosts」セクション
- Rails Guides: Configuring Rails Applications – 「
- 実際の活用例:
- docker-compose などで複数コンテナを異なるポートでローカル公開しており、特定のポートに対してのみ Rails アプリがリクエストを受け付けるようにしたい場合に、
config.hosts << "myapp.local:8080"のように設定できることが、今回のドキュメント変更で分かりやすくなります。
- docker-compose などで複数コンテナを異なるポートでローカル公開しており、特定のポートに対してのみ Rails アプリがリクエストを受け付けるようにしたい場合に、
#57503 Reject malformed hosts with extra ports
マージ日: 2026/6/1 | 作成者: @afurm
- 概要 (1-2文で)
HostAuthorization ミドルウェアが「許可ホストにポートを含めて設定した場合」にも誤って「追加の任意ポート」を許容してしまう問題(例:www.example.com:80:80が通ってしまう)を修正する PR です。ポートを含む許可ホストに対しては、余分なポートを受け入れないよう正規表現生成ロジックを変更し、対応するテストも追加しています。
- 変更内容の詳細
問題の背景
config.hosts や HostAuthorization ミドルウェアにおいて、許可ホストにポート番号を含めた値(例: "www.example.com:80")を指定したとき、内部ではそのホスト名から許可判定用の正規表現が生成されます。
従来の実装では:
- 「ポートなし」のホスト(例:
"www.example.com")→:80など任意のポートを許可するため、正規表現の末尾に「任意ポートのオプション部分」が付与される((?:\:\d+)?のようなイメージ)。 - ところが、「ポートあり」のホスト(例:
"www.example.com:80")に対しても、同様に「もう一個オプションのポート」を付けてしまっていた。
その結果:
- 許可リスト:
"www.example.com:80" - 実際の Host ヘッダ:
"www.example.com:80:80"
という不正な値でも、生成された正規表現にマッチしてしまい、HostAuthorization を通過 → その後段でリダイレクト処理などが「正しい Host 値が来る前提」で動いてしまう、というバグが発生していました。
この PR の修正内容
修正のポイントは「許可ホストにポートが含まれているかどうかで、生成する正規表現を分ける」ことです。
ホストが「ポートなし」の場合
- 従来どおり
example.com→^example\.com(?::\d+)?$のように、末尾に「任意ポートのオプション」を付けて、:3000など任意のポート付き Host を許可する。
- 従来どおり
ホストが「ポートあり」の場合(今回の修正箇所)
- 例:
example.com:80 - 既にポートが明示されているので、その後ろに「さらにもう一つ任意ポート」を追加しない。
- つまり
example.com:80なら、^example\.com:80$のように「ピッタリ一致」だけを許可する正規表現を生成するように変更。
- 例:
コード的には、ActionDispatch::HostAuthorization 内で許可ホストを正規表現に変換しているメソッド(compile_hosts かその周辺)において:
hostに:(ポート指定)が含まれているかどうかを判定- 含まれている場合は
(?::\d+)?のような「オプションのポート部分」を付けない - 含まれていない場合は従来どおり「オプションのポート部分」を付ける
という条件分岐が入った形になります。
追加されたテスト
actionpack/test/dispatch/host_authorization_test.rb に以下のような観点のテストが追加されています(内容のイメージ):
「明示的なポート付きホスト」が許可されること
- 設定:
config.hosts << "www.example.com:80" - Host:
"www.example.com:80"→ 許可される(ミドルウェアでブロックされない)。
- 設定:
「余分なポートを含むホスト」が拒否されること
- 同じ設定のもとで、Host:
"www.example.com:80:80"→ 許可されない(ミドルウェアで 403 などが返る)。
- 同じ設定のもとで、Host:
既存挙動(ポートなしホストに対して任意ポートを許容する挙動)が壊れていないこと
- 設定:
config.hosts << "www.example.com" - Host:
"www.example.com:3000"→ 従来通り許可される。
- 設定:
テストコマンド例として PR に記載されているもの:
cd actionpack && bin/test test/dispatch/host_authorization_test.rb -i "/extra ports/"
cd actionpack && bin/test test/dispatch/host_authorization_test.rb
cd actionpack && bundle exec rubocop lib/action_dispatch/middleware/host_authorization.rb test/dispatch/host_authorization_test.rb
git diff --check- 影響範囲・注意点
影響対象:
ActionDispatch::HostAuthorizationミドルウェアを利用している Rails アプリ(Rails 6 以降など)で、config.hostsにポート付きのホストを列挙しているケース。- 例:ruby
# config/environments/production.rb config.hosts << "example.com:443" config.hosts << "internal.example.com:3000"
この変更により起こる挙動の変化:
- これまでは(バグにより)
"example.com:443:443"のような不正な Host 値が通っていた可能性があるが、今後は 確実にブロック される。 - 正しい Host 値(
example.com:443)は従来同様に許可されるため、通常の正しいクライアントには影響しない。
- これまでは(バグにより)
注意点:
- 万が一、CDN / リバースプロキシ / 独自ミドルウェアなどが誤って
Hostヘッダを二重ポートのような形で付与していた場合、この変更適用後に 403 などでリクエストが落ちる可能性がある。- ただし、
Host: example.com:80:80といった形自体が RFC 的にもおかしい値なので、基本的には「バグを表面化させる」方向の改善と言える。
- ただし、
- 「ポートなしホストに対して、任意ポートを許可する挙動」は維持されているため、
config.hosts << "example.com"のような設定に関しては意図した挙動はそのままです。
- 万が一、CDN / リバースプロキシ / 独自ミドルウェアなどが誤って
- 参考情報 (あれば)
- 関連 issue: #37956
- 変更ファイル:
actionpack/lib/action_dispatch/middleware/host_authorization.rb- 許可ホストを正規表現に変換するロジックの条件分岐追加(ポート有無で「オプションポート」の扱いを変える)
actionpack/test/dispatch/host_authorization_test.rb- 明示ポートホスト・余分なポートを持つホストの挙動を検証するテストを追加
この変更により、Host ヘッダの検証がより厳密かつ安全になり、下流のリダイレクトや URL 生成ロジックが「正しい Host が来る」という前提で動きやすくなります。
#57526 Revert adding ractor helpers in Kernel
マージ日: 2026/6/1 | 作成者: @byroot
- 概要 (1-2文で)
このPRは、Ractor 関連のヘルパーメソッドをKernelに追加して Rails のパブリック API にする方針を撤回し、代わりに内部モジュール (ActiveSupport::Ractors) として提供するように変更しています。目的は、Ruby の古いバージョン互換のためだけに必要なヘルパーを「永久に維持すべきパブリック API」にしないようにすることです。
- 変更内容の詳細
背景
- 元の PR (#57467) では、Ractor 対応のために以下のようなヘルパーを
Kernelに追加し、Rails から利用しやすくしていました(例:ractor_shareable?やmake_ractor_shareableのようなもの)。 - しかし
Kernelにメソッドを追加すると、「Rails のパブリック API」とみなされ、将来的に不要になっても簡単には削除できません。 - これらのヘルパーは「古い Ruby で Ractor 関係の API が不足しているのを埋める」ための穴埋めであり、Ruby のサポートバージョンが上がれば不要になる見込みです。
主な変更点
1) Kernel 拡張の削除
削除されたファイル:
activesupport/lib/active_support/core_ext/kernel/ractor_shareability.rb
→Kernelに Ractor 関連のヘルパーを生やしていた拡張を丸ごと削除しています。
修正されたファイル:
activesupport/lib/active_support/core_ext/kernel.rb
→ 上記ファイルをrequireしていた行を削除。guides/source/active_support_core_extensions.md
→ Kernel 拡張としての Ractor ヘルパーに関するガイド記述を削除。
これにより、Kernel#xxx として Ractor ヘルパーを呼ぶ API は廃止されます(そもそもまだリリースされていない段階で戻した形)。
2) 内部モジュール ActiveSupport::Ractors の追加
新規ファイル:
activesupport/lib/active_support/ractors.rb(+69行)
ここに、元々 Kernel に定義していた Ractor ヘルパー群が「内部 util モジュール」として再定義されています。
Rails 内部や gem から使う場合は、以下のような形になることが想定されます:
# 内部的な利用イメージ(実際のメソッド名は元PR依存ですが概ねこんな感じ)
require "active_support/ractors"
if defined?(Ractor)
ActiveSupport::Ractors.shareable?(obj)
ActiveSupport::Ractors.make_shareable(obj)
endbyroot のコメントにもある通り、このモジュールは
- 単にモジュール関数(
module_function)として定義するか - singleton メソッドとして使うか
といった設計も含め、「内部 API をどのような形で提供するか」を議論可能な範囲に閉じ込める意図があります。
3) ActiveSupport 本体からの読み込み
activesupport/lib/active_support.rb
→require "active_support/ractors"が追加されました。
これにより、require "active_support" した環境から ActiveSupport::Ractors が利用できるようになります(あくまで内部 API の位置づけ)。
4) Changelog とドキュメントの整理
activesupport/CHANGELOG.md(-16行)
→ 前の PR (#57467) の内容として記載されていた、「Kernel に Ractor ヘルパーを追加した」というエントリが削除。guides/source/active_support_core_extensions.md(-40行)
→ Kernel 拡張としての Ractor 関連記述を削除。
つまり、「ユーザー向けの新機能として Kernel 拡張を追加した」という扱いそのものを取り消しています。
5) テストの更新
activesupport/test/ractors_test.rb(+12/-13)
→ これまでKernelに生えたメソッドを前提にしていたテストを、ActiveSupport::Ractorsを使う形に修正しています。
テスト対象が「パブリック Kernel API」から「内部 ActiveSupport モジュール」に変わったイメージです。
影響範囲・注意点
Rails アプリ/gem からの直接利用は想定していない
- そもそも Kernel 拡張がまだリリース前に revert された形なので、「既存コードが壊れる」という互換性問題は基本的に発生しない想定です。
- 新たに Ractor 向けヘルパーを使いたい場合も、Rails の「公認パブリック API」とは位置付けられていない
ActiveSupport::Ractorsに依存することになるため、将来的な変更・削除のリスクがあります。
Kernel にメソッドは増えない
- この PR の結果として、
Kernelに Ractor 関連のメソッドは追加されません。 - Ractor 周りで Ruby 本体と Rails が混ざった API になることを避け、「Rails 起因でグローバル名前空間を汚染しない」方針が保たれています。
- この PR の結果として、
将来的な削除を見越した設計
- 元コメントにある通り、Ruby のサポートバージョンが上がり、Ractor 関連の機能が標準で十分に提供されるようになれば、
ActiveSupport::Ractors自体が不要になり、内部実装・互換レイヤーとして削除できる余地が残されます。 - パブリック API にしてしまうと「古い Ruby のためだけのラッパー」を半永久的に維持しなければならないため、それを避けています。
- 元コメントにある通り、Ruby のサポートバージョンが上がり、Ractor 関連の機能が標準で十分に提供されるようになれば、
Ractor を積極利用するライブラリ作者への注意
- Rails の Ractor サポート状況を調べる際、「Kernel に Ractor 関連ヘルパーがある」という情報は誤りになったので注意が必要です。
- もし Rails 内部と同じヘルパーを使いたい場合は
ActiveSupport::Ractorsを読む形になりますが、それは互換保証のない内部 API です。
長期的に安定した API を求めるなら、Ruby 本体のRactorAPI を直接使用する方が安全です。
- 参考情報 (あれば)
- 元 PR(追加を行った側):
https://github.com/rails/rails/pull/57467 - この PR 本体:
https://github.com/rails/rails/pull/57526
設計上のポイント:
- 「互換性確保のための一時的な補助を、Kernel やパブリック API に乗せない」
- 「内部モジュール化して、Ruby バージョン戦略に応じて将来的に削除しやすくする」
という Rails の API スタビリティポリシーがよく表れている変更です。
#57529 Fix syntax for Rack response in Rack guide (#57527) [ci-skip]
マージ日: 2026/6/1 | 作成者: @p8
概要 (1-2文で)
Rails ガイド「Rails on Rack」に記載されている Rack レスポンスのサンプルコードの構文ミスを、正しい Rack 仕様に沿う形で修正したドキュメント修正PRです。コードや挙動には一切変更がなく、ガイドの記述のみが更新されています。変更内容の詳細
- 対象ファイル:
guides/source/rails_on_rack.md - 変更内容は 1 行のみで、Rack アプリケーションの返り値(Rack レスポンス)の書き方を正しい構文に修正しています。
Rack のアプリケーションは以下の形式の 3 要素配列を返す必要があります:
# [ステータスコード, ヘッダ(Hash), ボディ(各要素が文字列の Enumerable)]
[status, headers, body]ガイド内のサンプルでは、この 3 要素目(body)か、もしくは全体の書式が Rack 仕様に即していない形になっていたため、例えば次のような形に修正されていると考えられます:
修正前(誤った例・イメージ):
# body を単なる文字列で返している / または配列ではない、など
[200, { "Content-Type" => "text/html" }, "Hello, world!"]修正後(正しい Rack レスポンスの例):
[200, { "Content-Type" => "text/html" }, ["Hello, world!"]]あるいは単純なシンタックスエラー(カンマやカッコの抜け、シンボルの書き方など)があれば、それが修正されています。
いずれにせよ、修正内容は 「Rack アプリの返り値サンプルを、Rack が要求する [status, headers, body] 形式として正しく書き直した」 ものです。
- 影響範囲・注意点
影響範囲:
- Rails 本体・Rack インテグレーションの実装・挙動には影響しません。
- ドキュメント(Rails ガイド)を見て Rack アプリケーションを書く開発者が、誤ったレスポンス形式を参考にしてしまうリスクが減ります。
注意点:
- 既に誤ったサンプルをもとにアプリを書いていた場合、Rack のインターフェース仕様(body は
eachできる文字列の配列などである必要がある)を改めて確認するのがよいです。 - PR タイトルに
[ci-skip]が付いている通り、コード変更がないため CI はスキップされています。
- 既に誤ったサンプルをもとにアプリを書いていた場合、Rack のインターフェース仕様(body は
- 参考情報 (あれば)
- Rack SPEC(Rack アプリの返り値仕様)
- https://github.com/rack/rack/blob/main/SPEC.rdoc
- 「The Body must respond to
eachand must only yield String values.」など、レスポンス形式の要件が詳述されています。
- Rails ガイド: Rails on Rack
- 最新版の
rails_on_rack.mdを参照すると、今回修正された正しいサンプルコードを確認できます。
- 最新版の
#57527 Fix syntax for Rack response in Rack guide
マージ日: 2026/6/1 | 作成者: @ayushn21
- 概要 (1-2文で)
Railsガイド「Rails on Rack」で、Rackレスポンスの書き方に誤りがあったため、正しいRack::Responseオブジェクトを使う形に修正したドキュメント更新のPRです。コードの挙動ではなく、ガイドのサンプルコードの記述ミスを直しています。
- 変更内容の詳細 (サンプルコード例)
- 対象:
guides/source/rails_on_rack.mdの Rack レスポンスに関するサンプルコード - 内容: Rack アプリケーションの戻り値を「配列で返す例」から、「
Rack::Responseオブジェクトを使う例」に修正
Rails が Rack アプリを扱う際に、ガイド上で本来は以下のような「オブジェクトの戻り値」を示したかったところが、誤って単純な Rack の「配列レスポンス」のように書かれていた、という趣旨です。
イメージとしては、誤った例(※実際のPRとは文言が異なる可能性がありますがニュアンス):
# 誤: Rack レスポンスを単純な配列として記述していたケース
app = Proc.new do |env|
[200, { "Content-Type" => "text/html" }, ["Hello Rack!"]]
endを、Rails ガイドで意図している正しい形へ:
# 正: Rails が要求する形に即した Rack::Response オブジェクトの使用例
app = Proc.new do |env|
response = Rack::Response.new
response['Content-Type'] = 'text/html'
response.write 'Hello Rack!'
response.finish
endあるいは、シンプルな Rack::Response オブジェクトを直接返す形など、
「Rails がサポート・想定している Rack::Response ベースの書き方」に統一する修正です。
PR本文では「Rails が要求する形として Rack::Response オブジェクトを使うべきであり、配列ではない」という点が明示されています。
- 影響範囲・注意点
実コードへの影響
- 変更はガイド(ドキュメント)だけで、Rails 本体のコードには一切変更がありません。
- そのため、既存アプリケーションの挙動に影響はありません。
誤解解消の観点
- 以前のガイドの記述に従って、Rails の Rack エンドポイントやミドルウェアを「配列レスポンス前提」で書いていた場合、ガイドと実際の期待仕様とのズレが生じていた可能性があります。
- これを読んだ新規読者が「Rails 上の Rack アプリでは、
Rack::Responseを使うほうが正しい/推奨される」という点を理解しやすくなります。
実装時の注意
- Rails 上で Rack アプリやミドルウェアを書く場合:
Rack::Responseを利用すると、ヘッダ操作・Cookie 設定・レスポンス組み立てが明示的かつ安全に行いやすいです。response.finishで[status, headers, body]に変換されるため、Rack のインターフェイス要件も満たします。
- Rails 上で Rack アプリやミドルウェアを書く場合:
- 参考情報 (あれば)
- Rack 公式仕様(README)
- Rack アプリケーションのインターフェイス:
call(env) -> [status, headers, body] Rack::Responseはこの標準インターフェイスをラップするヘルパークラスで、finishで配列を返します。
- Rack アプリケーションのインターフェイス:
- Rails ガイド: Rails on Rack
- Rails を Rack アプリケーション/ミドルウェアとして扱う方法や、
config.ruでの設定などを説明しているドキュメントで、そのサンプルコードの一部が今回修正対象となっています。
- Rails を Rack アプリケーション/ミドルウェアとして扱う方法や、
#57516 Fix Duration#in_* truncating sub-second precision
マージ日: 2026/6/1 | 作成者: @55728
- 概要 (1-2文で)
ActiveSupport::Durationのin_minutes,in_hours,in_days,in_weeks,in_months,in_yearsが、内部で秒数を整数に切り捨ててから計算していたため、サブ秒精度が失われていたバグを修正する PR です。in_seconds/to_iの挙動はそのままに、in_*系メソッドだけが小数を正しく扱うようになります。
- 変更内容の詳細
問題点
元の実装では、各 in_* メソッドが in_seconds(= to_i のエイリアス)を用いており、そこで小数部分が切り捨てられていました。
def to_i
@value.to_i
end
alias :in_seconds :to_i
def in_minutes
in_seconds / SECONDS_PER_MINUTE.to_f
endこのため、@value にサブ秒(小数)が含まれていても、to_i のタイミングで整数へ丸められ、以降の計算結果が不正確になっていました。
例:
90.5.seconds.in_minutes # 1.5 (実際は 1.5083... が正しい)
0.5.seconds.in_days # 0.0 (実際は ~5.78e-06)
3661.5.seconds.in_hours # 1.0169444... (実際は 1.0170833...)in_* のドキュメントは「float を返す」となっているため、サブ秒精度を落とす現在の挙動は仕様ではなくバグと判断されています。
修正内容
in_* 系メソッドで、整数に丸められた in_seconds ではなく、元の value(サブ秒を含む実数値)を用いて計算するように変更されています。
ざっくりいうと、次のようなイメージです:
# 変更前 (イメージ)
def in_minutes
in_seconds / 60.0 # in_seconds は to_i 済み
end
# 変更後 (イメージ)
def in_minutes
value / 60.0 # value はサブ秒を含む実数
end実際のパッチでは、SECONDS_PER_* 定数を使って、同様の修正が in_minutes, in_hours, in_days, in_weeks, in_months, in_years 全てに適用されています。
テスト
activesupport/test/core_ext/duration_test.rb に以下のようなテストが追加されています(趣旨だけ抜粋):
def test_in_units_preserve_sub_second_precision
assert_in_delta 1.5083333333, 90.5.seconds.in_minutes
assert_in_delta 5.78e-06, 0.5.seconds.in_days
assert_in_delta 1.0170833333, 3661.5.seconds.in_hours
end- 追加テストは
mainブランチでは red(失敗)、本修正適用後に green(成功)になることを確認済み。 - 既存の
duration_test.rbの全テストもグリーンで通過。
- 影響範囲・注意点
- 影響を受けるのは、サブ秒(小数秒)を含む
ActiveSupport::Durationをin_minutes,in_hours,in_days,in_weeks,in_months,in_yearsで変換しているコードです。- これまで: 小数秒が切り捨てられ、実際より小さい値(あるいは 0)になる場合があった。
- 今後: サブ秒を含めたより正確な float が返ってきます。
- 整数秒のみを扱っているコードへの影響はありません。
Integer / 60.0とInteger.to_i / 60.0は同じなので、既存の挙動は変わりません。
in_seconds/to_iはこれまで通り整数への切り捨てです。- 整数変換の挙動を前提にしたコード(例: ログ出力、インデックス計算など)は影響を受けません。
- 精度が向上することで、「いままで暗黙に切り捨てられていたサブ秒分」が計算に含まれるようになるため、
- 厳密な数値比較テスト(
==比較)をしている場合、テストが落ちる可能性があります。 - 特に「期待値を古い不正確な値で固定している」テストは見直しが必要になります。
- 厳密な数値比較テスト(
- 過去約 5.7 年にわたり未テストの経路だったため、逆に言えば「この不正確な挙動に依存したコード」はあまり多くないことが期待されますが、ライブラリや長寿命のアプリでは一応の確認が必要です。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/57516
- 該当実装ファイル:
activesupport/lib/active_support/duration.rb - 該当テスト:
activesupport/test/core_ext/duration_test.rb - バグの導入コミット:
7bd9603778c(2020-03-11) で初登場a2535d9fe9(2020-08-26) でin_*にリネーム
#57514 Hash PostgreSQL cable channel identifiers by byte size
マージ日: 2026/6/1 | 作成者: @55728
- 概要 (1-2文で)
Action Cable の PostgreSQL サブスクリプションアダプタで、チャネル名の長さ判定を「文字数」ではなく「バイト数」で行うように変更し、マルチバイト文字を含むチャネル名が PostgreSQL の識別子上限(63バイト)でサイレントに切り詰められる問題を修正した PR です。
これにより、長い日本語名などを使ったチャネルで発生し得た「メッセージのサイレントロス」および「別チャネルへの誤配送」が解消されます。
- 変更内容の詳細
何を直したか
Action Cable の PostgreSQL アダプタでは、PostgreSQL の識別子長上限(NAMEDATALEN - 1 = 63 バイト)を超えるチャネル名は SHA1 でハッシュ化していましたが、その判定に String#size(文字数)を使っていました。
# 旧実装
def channel_identifier(channel)
channel.size > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel
endしかし、PostgreSQL の上限は「文字数」ではなく「バイト数」です。UTF-8 の日本語(3バイト/文字)などの場合、
- 21文字の「あ" * 21` → 63バイト
- 30文字 + 1文字 (
"あ" * 30 + "X") → 91バイト
といったように、63文字未満でも63バイトを超えることがあり得ます。size <= 63 だとハッシュ化されずそのまま LISTEN/NOTIFY に使われ、PostgreSQL 側でサイレントに 63バイトへ切り詰められます。
この結果、Action Cable 内部で使っている「チャネル識別子」と、PostgreSQL 側から wait_for_notify で返ってくる識別子とで不整合が発生していました。
具体的な不具合
Action Cable の PostgreSQL アダプタはざっくり次のような構造を持っています。
- 購読時:
channel_identifier(channel)をキーとした Ruby のハッシュに subscriber を登録 - 受信時:
PG#wait_for_notifyが返すチャネル名をキーに subscriber を引いて dispatch
ここで、以下のような状況を考えます(DB は UTF-8):
S = "あ" * 21 # 21文字 / 63バイト → size=21 <= 63 なのでハッシュされない
L = ("あ" * 30) + "X" # 31文字 / 91バイト → size=31 <= 63 なのでハッシュされない
# ただし PostgreSQL は 63バイトに切り詰め → "あ" * 21 == S- Ruby 側登録キー
channel_identifier(S)→"あ"*21(63バイト)channel_identifier(L)→"あ"*30 + "X"(91バイト、そのまま)
- PostgreSQL 側から
wait_for_notifyで返るキーNOTIFY L→"あ"*21(91バイト → 63バイトにトリムされて S と同一)
このため、2パターンの不具合が生じます:
サイレントメッセージロス
長いチャネルLへ broadcast しても、wait_for_notifyが返すキーは"あ"*21(S相当)になるが、登録時のキーは"あ"*30 + "X"(91バイト)で一致しないため、Lの subscriber には一切メッセージが届かない。別ストリームへの誤配送
もし"あ"*21(S)にも別の subscriber が存在している場合、wait_for_notifyのキー"あ"*21でハッシュを引くと S 側の subscriber がヒットするため、
本来 L 向けのメッセージが S に誤配送され、L は何も受け取れない。
この問題は、マルチバイトで63バイトを超えたチャネル名を使い、かつその63バイトへの切り詰め結果に一致する別のチャネルが存在する場合に顕在化します。
日本語などの多バイト言語をチャネル名に使うと、実務上発生し得るパターンです。
修正内容
判定を「文字数」から「バイト数」に変更しました。
# 新実装
def channel_identifier(channel)
channel.bytesize > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel
endbytesize > 63の場合のみ SHA1 ハッシュ(40バイト文字列)に変換- SHA1 は常に 40文字(40バイト)なので、PostgreSQL の 63バイト制限を超えない
- 結果として、
- PostgreSQL に渡す識別子
- PostgreSQL から返ってくる識別子
- Ruby 側の登録時キー
が再び一致するようになり、上記のロス・誤配送が解消されます。
- ASCII だけのチャネル名は
size == bytesizeのため挙動は従来と変わりません。
テスト追加
actioncable/test/subscription_adapter/postgresql_test.rb に以下の end-to-end テストを追加しています。
- テスト名:
test_long_multibyte_identifiers - 実際の
LISTEN/NOTIFYを使う harness(subscribe_as_queue/@rx_adapter/@tx_adapter)で動作確認 - シナリオ:
long(91バイト、日本語 + 英字)とshort("あ"*21, 63バイト)両方に subscribelongに対して broadcast- 2つの assertion:
long側 subscriber が payload を受け取ること(旧実装ではロス)short側 subscriber は何も受け取らないこと(旧実装では誤配送)
- 受信は
Queue#pop(timeout: ...)でタイムアウト付きにして、旧実装時もテストがハングせずにすぐ失敗するよう工夫されています。 String#size版では両 assertion が赤、String#bytesizeに変更すると緑になることを確認済み。
- 影響範囲・注意点
- 対象:
- Action Cable を PostgreSQL アダプタ(
ActionCable::SubscriptionAdapter::PostgreSQL)で利用しているアプリケーション - チャネル名(stream 名)に UTF-8 のマルチバイト文字を使い、合計バイト数が 63バイトを超えるケース
- Action Cable を PostgreSQL アダプタ(
- 影響:
- 従来: 63文字以内であれば、バイト数が 63を超えていても「ハッシュされず」そのまま識別子として使われていた
- 今回: 63バイトを超えた時点で必ず SHA1 ハッシュに変換される
- 互換性上の注意:
- チャネル名 → PostgreSQL 上の identifier へのマッピングが、一部ケースで「素の名前」から「SHA1 ハッシュ」へと変わるため、
- 旧バージョンの Rails / adapter と新バージョンの adapter を混在運用している場合、
- あるいは Action Cable のチャネル名を直接参照して PostgreSQL 側の
LISTEN/NOTIFYを行っている自前コード
などがあると、識別子の不一致が発生する可能性があります。
- 一般的な Rails アプリ(Action Cable も Rails 側 API 経由のみ利用)では、多くの場合は透過的な変更となります。
- チャネル名 → PostgreSQL 上の identifier へのマッピングが、一部ケースで「素の名前」から「SHA1 ハッシュ」へと変わるため、
- セキュリティ・安定性:
- SHA1 の利用はあくまで識別子の短縮用途であり、暗号学的安全性が問題になるわけではありません(元々そういう用途として導入されたもの (#28751))。
- 修正により、意図しない cross-delivery / silent drop が解消され、安定性・予測可能性は向上します。
- 参考情報 (あれば)
- PostgreSQL の識別子長制限:
NAMEDATALENは通常 64- 実際の識別子長上限は
NAMEDATALEN - 1 = 63バイト - これを超える識別子はサイレントに切り詰められ、NOTICE が出るのみ
- 関連する過去の変更:
- もともとのハッシュ化ロジックは #28751(commit
2bce7777b7)で導入されており、今回の変更は「長さ判定を PostgreSQL の仕様(バイト数)に合わせて是正した」ものです。
- もともとのハッシュ化ロジックは #28751(commit
- 実務的な示唆:
- WebSocket / PubSub 系で DB をバックエンドに使う場合、識別子長の単位(文字数ではなくバイト数)に注意が必要
- マルチバイトを多用するシステムでは、UI 上の「文字数制限」だけでは不十分なことがあるため、バックエンド側のバイト数制限も意識する必要があります。
#57505 Parse all HTTP-date formats in If-Modified-Since
マージ日: 2026/6/1 | 作成者: @55728
- 概要 (1-2文で)
If-Modified-Sinceヘッダの日時パースをTime.rfc2822からTime.httpdateに変更し、RFC 9110 で必須とされる3種類すべての HTTP-date 形式(IMF-fixdate / RFC 850 / asctime)を正しく解釈できるようにしたPRです。これにより、これまで一部フォーマットで304 Not Modifiedが返せずフルレスポンスになっていた挙動が修正されます。
- 変更内容の詳細
背景
If-Modified-Sinceは HTTP-date を持つヘッダで、RFC 9110 §5.6.7 では次の3フォーマットを定義し、かつ「すべて受理しなければならない(MUST)」としています:- IMF-fixdate (例:
Sun, 06 Nov 1994 08:49:37 GMT) - RFC 850 (例:
Sunday, 06-Nov-94 08:49:37 GMT) - asctime (例:
Sun Nov 6 08:49:37 1994)
- IMF-fixdate (例:
既存実装では
ActionDispatch::Http::Cache::Request#if_modified_sinceがTime.rfc2822でパースしていました:rubydef if_modified_since if since = get_header(HTTP_IF_MODIFIED_SINCE) Time.rfc2822(since) rescue nil end endTime.rfc2822は IMF-fixdate 相当の形式しか受け付けないため、RFC 850 / asctime 形式で送られたIf-Modified-Sinceはパースに失敗し、rescue nilにより「ヘッダが無いかのように」扱われていました。
→not_modified?が常にfalseになり、本来なら304 Not Modifiedを返せるケースでもフルレスポンスが返されていた。一方で、同じファイル内のレスポンス側(
Last-ModifiedやDateの読み取り)は既にTime.httpdateを使っており、リクエスト側だけが不整合な状態でした。
修正内容
if_modified_since のパーサーを Time.httpdate に変更:
def if_modified_since
if since = get_header(HTTP_IF_MODIFIED_SINCE)
Time.httpdate(since) rescue nil
end
endTime.httpdateは HTTP-date の3フォーマットすべてをサポートするため、RFC 9110 が要求する仕様に沿った挙動になります。- ブラウザや Rails が自分で生成する日付(
Time#httpdate)は IMF-fixdate なので、一般的なケースの挙動は変わりません。
再現例と修正後の挙動
req = ->(ims) { ActionDispatch::Request.new(Rack::MockRequest.env_for("/", "HTTP_IF_MODIFIED_SINCE" => ims)) }
mtime = Time.utc(1994, 11, 6, 8, 49, 37)
# 1. IMF-fixdate: 以前から OK
req.("Sun, 06 Nov 1994 08:49:37 GMT").not_modified?(mtime)
# => true
# 2. RFC 850: 修正前は nil(ヘッダ無しと同等)→ 修正後は true
req.("Sunday, 06-Nov-94 08:49:37 GMT").not_modified?(mtime)
# => 修正前: nil / 修正後: true
# 3. asctime: 同様に修正前 nil → 修正後 true
req.("Sun Nov 6 08:49:37 1994").not_modified?(mtime)
# => 修正前: nil / 修正後: trueテスト
actionpack/test/dispatch/request_test.rb に RequestIfModifiedSince テストケースを追加:
- 3種類の HTTP-date 形式が
if_modified_sinceで正しくパースされ、not_modified?が期待どおりtrueになること。 - パースできない日付文字列が渡された場合は
nilを返すこと。 - ヘッダが存在しない場合も
nilを返すこと。
既存コードでは RFC 850 / asctime に対するテストが失敗し、この修正を適用するとパスすることが確認されています。
- 影響範囲・注意点
影響範囲
- 対象:
If-Modified-Sinceによる条件付き GET (not_modified?) を利用するすべての Rails アプリ。 - 変更点:
- RFC 850 / asctime 形式を利用するクライアント・プロキシに対し、これまでより 積極的に 304 を返す ようになります。
- これまでは「
If-Modified-Sinceが無いもの」と見なされていたケース(= 常にフルレスポンス)で、正しく条件付きレスポンスが機能するようになります。
- 性質:
- キャッシュ・帯域最適化に関する改善であり、誤ったコンテンツを返すリスクはありません(最悪でもフルレスポンスが返るだけ)。
Time.httpdate の厳格さによる差異
Time.httpdateはタイムゾーンにGMTを要求します。- 例えば
Sun, 06 Nov 1994 08:49:37 +0000のような「RFC 2822 的には妥当だが HTTP-date としては非準拠」な値は、Time.httpdateではパースエラーになります。- 以前:
Time.rfc2822がパースしてTimeオブジェクトを返す。 - 以後:
Time.httpdateが例外を投げ、rescue nilによってnilとなる → ヘッダ無し相当の扱い →304を返し損ねてフルレスポンスになる。
- 以前:
- ただし、これらは HTTP-date としてそもそも不正なフォーマット なので、仕様準拠を優先した形です。
- 挙動の変化も「本来 304 を返すべきではないヘッダに対して 304 を返さない」「結果的にフルレスポンスになる」だけであり、安全側に倒れていると言えます。
互換性の総括
- 仕様通りの HTTP-date (
GMT、3フォーマットのいずれか) を送るクライアントは完全に後方互換であり、挙動は変わらないか、RFC 850 / asctime の場合に改善されます。 - 非準拠クライアント(
+0000など)に対しては、304 を返さずフルレスポンスになる方向への変化のみが発生し、誤った 304 が返るような形にはなりません。 - サーバ側とクライアント側での HTTP-date 取り扱いが
Time.httpdateで統一されたことで、ActionDispatch 内の一貫性も向上しています。
- 参考情報 (あれば)
- RFC 9110 §5.6.7: HTTP-date の定義(IMF-fixdate / RFC 850 / asctime、および GMT 必須)
https://www.rfc-editor.org/rfc/rfc9110#section-5.6.7 - Rails 内の関連箇所:
ActionDispatch::Http::Cache::Request#if_modified_since(本PRで修正)- 同ファイル内での
Last-Modified/Dateの読み取りロジック(既にTime.httpdateを使用)
#57519 Fix store accessor *_change and saved_change_to_* reporting unchanged keys as changed
マージ日: 2026/6/1 | 作成者: @55728
- 概要 (1-2文で)
ActiveRecord::Storeで生成される*_change/saved_change_to_*が、実際には値が変わっていないキーに対しても[value, value]を返してしまう不具合を修正した PR です。
これにより、通常の ActiveModel の dirty トラッキング API と同様に、値が変わっていないキーに対してはnilを返すようになります。
- 変更内容の詳細
何が問題だったか
ActiveRecord::Store では、store / store_accessor で定義したキーごとに、次のような dirty メソッドが自動生成されます。
<key>_changed?<key>_changesaved_change_to_<key>?saved_change_to_<key>
このうち *_change / saved_change_to_* の実装が、カラム単位の変更有無(attribute_changed?(store_attribute) / saved_change_to_attribute?(store_attribute))しか見ておらず、キー単位で値が変わったかどうかを見ていなかったのが問題でした。
元の挙動は以下のようになります。
user = User.create!(color: "black", size: "L").reload
user.color = "white" # color だけ変更
user.size_changed? # => false (キー単位のpredicateは正しい)
user.size_change # => ["L", "L"] (バグ、本来は nil であるべき)
user.save!
user.saved_change_to_size? # => false (正しい)
user.saved_change_to_size # => ["L", "L"] (バグ、本来は nil)つまり、
- ストアカラム(例:
settings)のどれか1キーでも変更があると - そのカラム内の 他のキー についても
<key>_changed?/saved_change_to_<key>?はfalse(正しい)- なのに
<key>_change/saved_change_to_<key>は[value, value]を返す(間違い)
という不整合な状態になっていました。
ActiveModel の通常の dirty API (attribute_change, saved_change_to_attribute) では「値が変わっていない場合は nil を返す」のが契約であり、これに反していたことになります。
どう修正されたか
activerecord/lib/active_record/store.rb の *_change / saved_change_to_* の実装において、キー単位での値比較を行い、変化がなければ nil を返すように修正されています。
疑似コードレベルでいうと、これまで:
define_method("#{accessor_key}_change") do
return unless attribute_changed?(store_attribute)
prev_store, new_store = changes[store_attribute]
accessor = store_accessor_for(store_attribute)
[accessor.get(prev_store, key), accessor.get(new_store, key)] # 変わってなくても常に返していた
endだったものが、以下のように変更されています:
define_method("#{accessor_key}_change") do
return unless attribute_changed?(store_attribute)
prev_store, new_store = changes[store_attribute]
accessor = store_accessor_for(store_attribute)
prev_value = accessor.get(prev_store, key)
new_value = accessor.get(new_store, key)
[prev_value, new_value] unless prev_value == new_value
endsaved_change_to_<key> についても同様に、前後の値を比較して同値であれば nil を返すようになっています。
変更されていない挙動
以下のメソッドは今回の修正対象外で、従来どおりの挙動を保っています。
<key>_was<key>_before_last_save
これらは通常の attr_was / attribute_before_last_save と同様、「キーが変更されていない場合は現在値を返す」という契約であり、nil にはなりません。この契約は dirty pair (*_change) の契約とは別物なので、そのまま維持されています。
テスト
activerecord/test/cases/store_test.rb にリグレッションテストが追加されています。
ストアに複数のキー(例:
color,homepage)を定義homepageに実データ(URL)を入れた状態でcolorだけ変更したときにhomepage_changed?/saved_change_to_homepage?がfalseであることに加えてhomepage_change/saved_change_to_homepageがnilを返すこと
を検証しています。
PR内で main ブランチに対してテストが「red → fix 適用後 green」になることが確認されています。
- 影響範囲・注意点
影響を受けるコード
影響を受けるのは、store accessor の *_change / saved_change_to_* の戻り値を直接利用しているコードです。
たとえば、次のようなコードを書いている場合:
before_save do
prev, cur = settings_change_for_size = [size_change].flatten
# もしくは
prev, cur = size_change if size_change
# あるいは
prev, cur = saved_change_to_size
end以前は「サイズは変わっていないが、同じストア内の他のキーだけが変わった」という場合に ["L", "L"] のようなペアが返ってきていましたが、これが nil に変わります。
よくあるパターン:
prev, cur = size_change
if prev != cur
# ... 何か処理 ...
endこうしたコードは、これまでは prev != cur が false になって何も起きない、という「一応安全だが無駄な比較」状態でしたが、今後は size_change が nil になり、prev, cur = size_change で TypeError になる可能性があります。
とはいえ、dirty pair の契約に従うコードであれば、本来は以下のように nil チェックを行うべきです:
if (change = size_change)
prev, cur = change
# ... 何か処理 ...
endあるいは:
if size_changed?
prev, cur = size_change
# ... 何か処理 ...
endそのため、契約どおりの使い方をしている限りは問題になりにくく、挙動はより一貫性のあるものになります。
後方互換性の観点
- ActiveModel の標準的な dirty トラッキング (
attribute_change,saved_change_to_attribute) と同じ挙動になるよう修正されているため、API 契約としてはむしろ「正しい」方向への変更です。 - ただし、過去の「間違った挙動」に暗黙的に依存していたコード(「変わっていない場合も
[value, value]が返る」前提)は壊れる可能性があります。 *_was/*_before_last_saveはこれまでどおり「変わっていない場合も現在値を返す」ので、こちらに依存しているコードは影響を受けません。
影響レベル
PR説明上は「Medium」とされており:
- セキュリティインパクトはなし
*_changed?/saved_change_to_*?はもともと正しかったため、これらの predicate を利用するコードは基本的に影響なし- 直接ペアを読んでいるコードのみが対象
という整理です。
- 参考情報 (あれば)
- 対象コード:
activerecord/lib/active_record/store.rbstore_accessor周りの dirty メソッド (<key>_change,saved_change_to_<key>) の実装
- テスト:
activerecord/test/cases/store_test.rb- 兄弟キー(同じ store カラム内の別キー)が変化したときの
*_change/saved_change_to_*の戻り値の検証
- 兄弟キー(同じ store カラム内の別キー)が変化したときの
- バグの由来:
- store の dirty メソッドが追加された 2019-03-25 (
61a39ffcc6) から存在 - 2025年の
97df37b898で値の取得方法をdig→accessor.getに変えたが、ガードロジック自体はそのまま残っていたためバグは解消されていなかった
- store の dirty メソッドが追加された 2019-03-25 (
store accessor の dirty 情報を直接扱うコードを書いている場合は、この PR の変更(「変化していないキーでは *_change / saved_change_to_* が nil を返す」)を前提に、nil ハンドリングが適切かを一度確認しておくと安全です。
#57515 Fix PostgreSQL range bounds parser corrupting comma-containing bounds
マージ日: 2026/6/1 | 作成者: @55728
- 概要 (1-2文で)
PostgreSQL の range 型を ActiveRecord が文字列からパースする際、境界値にカンマが含まれていると誤って分割してしまい、両端の値が壊れる不具合を修正した PR です。特にmoneyrangeでは$1,000.00のような通常の値であっても0.0..0.0に化けるという、重大なサイレントデータ破壊を防ぐ修正です。
- 変更内容の詳細
問題のあったコードとバグの内容
対象は PostgreSQL::OID::Range#extract_bounds(activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb)で、range のテキスト表現から下限・上限を取り出す処理です。
以前は、外側の括弧・ブラケットを落とした後、
value[1..-2].split(",", 2)と 先頭のカンマで単純に split していました。
PostgreSQL の range 表現の仕様として、
- 境界値にカンマやスペース、クオート等が含まれる場合は
"..."で ダブルクォートしてエスケープ する - 例:
["a,b","c,d"),["$1,000.00","$2,000.50"]
という挙動がありますが、split(",", 2) だと ダブルクォートを考慮せず 生の文字列を分割してしまうため、以下のようなバグが発生していました。
例: ["a,b","c,d") をパースする場合
value[1..-2] # => " "a,b","c,d" "
split(",", 2) # => ["\"a", "b\",\"c,d\""]結果として、ActiveRecord から見える range は
# 本来
range.string_range # => "a,b"..."c,d"
# 実際 (before)
range.string_range # => "\"a"..."b\",\"c,d\""のように両端とも壊れていました。
特に危険な moneyrange
money 型のテキスト表現はロケールによっては 3 桁ごとにカンマ区切り を含みます(例: "$1,000.00")。そのため moneyrange で次のようなデータを持っていると:
'["$1,000.00","$2,000.50"]'::moneyrangeActiveRecord 側では分割後に money 型としてキャストされる際、壊れた "\"$1 などの断片が 0 に解釈されてしまい、結果として 0.0..0.0 という完全な誤値 になります。これは異常値にも見えず、検知が困難なため深刻です。
修正内容
1. カンマ分割を「クオート対応」なものに変更
split(",", 2) を、ダブルクォートを理解した split_bounds メソッドに置き換えました。
BOUNDS = /\A((?:"(?:[^"\\]|""|\\.)*"|[^,"])*),(.*)\z/m # :nodoc:
def split_bounds(value)
# 高速パス: ダブルクォートを含まなければ従来通り split(",", 2)
return value.split(",", 2) unless value.include?('"')
(m = BOUNDS.match(value)) ? [m[1], m[2]] : [value, nil]
endポイント:
value.include?('"')が なければ従来どおりString#split(",", 2)を使用
→ int4range/numrange/tsrange/daterange など、通常の組み込み range 型はパフォーマンス影響がほぼない- ダブルクォートが含まれる場合のみ、
BOUNDS正規表現で- 「ダブルクォートで囲まれたセグメント」をスキップしつつ
- 「境界を分ける 1 個のカンマ」を検出して 下限・上限に綺麗に分割する
正規表現の構造:
\A(
(?:
"(?:[^"\\]|""|\\.)*" # "..." の中。 ""(二重引用)と \x のエスケープも許容
| # または
[^,"] # カンマ・ダブルクォート以外の任意文字(= 非クオート領域)
)*
), # ↑で取ったものの直後のカンマが境界
(.*)\z # 残りが上限側
/m[^,"]にしているのは、
「クオートされた部分」と「クオートされていない部分」が正規表現上で競合しないようにするため(曖昧性排除とパフォーマンスのため)/mフラグにより、上限側(2 番目のキャプチャ)が改行をまたいでも取得できるようにしている
2. unquote の nil 耐性向上
元コードでは、split(",", 2) が失敗すると to == nil になり、後続の unquote(to) 内で nil.start_with? により NoMethodError が発生する可能性がありました。
今回の変更で split_bounds はマッチ失敗時に [value, nil] を返すようになり、unquote 側も nil をそのまま返すようにして、不正入力時はクラッシュではなく「無変換の値 or nil」として処理されるようになっています。
(有効な PostgreSQL 出力では必ずカンマが含まれるため、このフォールバックパスは基本的に壊れた入力への備えです。)
3. テスト追加
activerecord/test/cases/adapters/postgresql/range_test.rb に、以下の新規テストが追加されています(いずれも PostgreSQL 実機に対してレッド → 修正後グリーンを確認済み):
- カンマを含むクオート境界の基本ケース
["a,b","c,d")が"a,b"..."c,d"として読み戻せること
- クオート+カンマ+ダブルクォート+バックスラッシュ混在
["a,""b","c\\,d")のような複雑ケースのパース検証
- 片側のみクオートされている場合
["x,y",z)
- 片側無限境界(beginless / endless)+カンマ
["a,b",)と[,"c,d")
- 境界に改行を含むケース
/mフラグを外すと壊れるケースのドキュメント兼テスト
- 空文字列
""を境界に含むケース["","z")が、空境界("")と無限境界(nil)を区別して扱えていること
これにより、クオート・エスケープ・カンマ・改行・空文字といった典型的な落とし穴を網羅的にカバーしています。
4. パフォーマンス評価
extract_bounds は deserialization のホットパスであるため、速度劣化がないようベンチマークも行われています。Ruby 4.0.2 + benchmark-ips での計測結果:
- ダブルクォートを含まないケース(通常の int/date 等)
- 旧:
split(",", 2)単体 - 新:
include?('"')➜split(",", 2)のハイブリッド
→ ほぼ 1.2 倍程度のオーバーヘッドに収まる(数百 ns レベル)
- 旧:
- ダブルクォートを含むケース(今回バグっていた text/varchar/money など)
- split は速いが 結果がバグっている
- regexp ベースの
split_boundsは ~300ns 程度
→ deserialization 全体(gsub,subtype.deserialize,Rangeオブジェクト生成など)に比べれば十分小さい
また、「手書き 1 パスの Ruby スキャナ」も試されたものの、必ずしも正規表現より速くならなかったため採用されていません。
- 影響範囲・注意点
影響を受ける可能性があるケース
- PostgreSQL 側で以下のような range 型を使用しており、ActiveRecord で読み書きしているアプリケーション:
text/varcharサブタイプのカスタム range 型- 例:
CREATE TYPE textrange AS RANGE (subtype = text);など
- 例:
moneyサブタイプの range 型(特に危険)- 例:
CREATE TYPE moneyrange AS RANGE (subtype = money);
- 例:
PostgreSQL では pg_range カタログに登録されたすべての range 型 (typtype = 'r') が、ActiveRecord 起動時に PostgreSQL::OID::Range.new(subtype, typname) として 自動登録 されるため、「カスタム range 型だから非サポート」という扱いではありません。全てサポート対象であり、そのうち「クオートされた境界にカンマが入りうるもの」は影響を受けます。
一方で、次のような標準 range 型はそもそもカンマを含まないテキスト表現のため影響なし(ただし include?('"') の 1 パス分だけは増える)です。
int4range/int8rangenumrangetsrange/tstzrangedaterange
(これらはカンマもクオートも通常出力では使われません)
実運用上のリスク
- すでに本バグを含んだ Rails でアプリを運用していた場合、
moneyrangeやtextrange等から ActiveRecord 経由で値を読んだ瞬間に「壊れた値」がアプリロジックの中で利用されていた可能性があります。- ただし「DB の中身(ディスク上の値)」自体は壊れておらず、パース時に壊れていただけ なので、修正後に再読み込みすれば正しい値が取得されます。
- ログや監査、集計値などに影響が波及している可能性があるため、
moneyrangeを使っている場合は特に挙動確認を推奨します。
マイグレーション / 互換性上の注意
- range のテキストフォーマットや PostgreSQL 側の動作を変えているわけではなく、あくまで Rails 側のパーサの修正のみです。
- 有効な PostgreSQL の range 文字列表現に対しては後方互換であり、「以前は通っていたけど今は例外になる」というような変更は意図的には入っていません(むしろ逆に、不正入力に対しては例外から「穏当な失敗」へと挙動が緩和されています)。
- したがって、ほとんどのアプリケーションは「アップデートすると正しくなる」だけで、追加でのコード変更は不要です。
- 参考情報 (あれば)
- 該当コード:
activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rbextract_bounds内部でsplit_boundsが利用される形に変更
- range 型の自動登録:
activerecord/lib/active_record/connection_adapters/postgresql/type_map_initializer.rbpg_rangeカタログを走査し、OID::Range.new(subtype, typname)として type map に登録
- バグの由来:
- 2019-11-06: quoted bound 対応 (
unquote) を追加したが、分割ロジックはカンマを無条件 split のままだった (45d7dad578) - 2020-05-29:
split(",")→split(",", 2)に変更したのみ (c65864cdca)
→ 5.5 年ほど潜伏していた不具合
- 2019-11-06: quoted bound 対応 (
この PR により、PostgreSQL の range 表現仕様(ダブルクォート・エスケープ・カンマ・改行)に正しく追従したパースが行えるようになり、特に moneyrange のような実害の大きいケースでのサイレントデータ破壊が解消されます。
#57476 Rescue RedisClient::Error in RedisCacheStore failsafe
マージ日: 2026/6/1 | 作成者: @darrunategui
- 概要 (1-2文で)
ActiveSupport::Cache::RedisCacheStoreの「Redis障害時でも例外を投げずにフェイルセーフに動作する」という契約が、Redis Sentinel など一部構成で破れていた問題を修正するPRです。RedisClient::Error系の例外もフェイルセーフで握りつぶすようにし、テストとCHANGELOGを追加しています。
- 変更内容の詳細
2-1. 問題の背景
RedisCacheStore はドキュメント上、以下のような動作が期待されています:
- Redis サーバが落ちている・一時的に接続できない場合
- 例外はアプリ側に伝播しない
readは常にミス扱い (nil等)writeやdeleteなどは黙って失敗(エラーを投げない)
これまでの実装は、Redis::Client#translate_error! が投げる Redis::BaseError など「Redis::* 階層の例外」だけを rescue していました。
しかし、以下のような構成時に問題がありました:
Redis.new(sentinels: ...)で Sentinel を使うと、内部的にclient_implementation: ::RedisClientが強制される- このとき、プールされている接続は
Redis::Clientではなく素の::RedisClientになる - そのため
Redis::Client#translate_error!を経由せず、RedisClient::ConnectionError,RedisClient::ReadTimeoutErrorなど 生のRedisClient::Errorサブクラスが直接飛ぶ RedisCacheStoreのフェイルセーフはRedis::*系だけを rescue していたため、これらRedisClient::Errorはすり抜けてアプリケーションエラーとなってしまう
これは、過去にあった ConnectionPool::TimeoutError がフェイルセーフをすり抜けていたバグ (#54432) と同種の問題です。
2-2. コード上の主な変更点
2-2-1. フェイルセーフで捕捉する例外の拡張
RedisCacheStore#failsafe(内部で使用されている例外捕捉ラッパー)で rescue する対象に ::RedisClient::Error を追加しました。
変更点のポイント:
- フェイルセーフが rescue する例外クラスのリストを、プライベート定数
FAILSAFE_ERRORSとして切り出し - その中に
::RedisClient::Errorを条件付きで追加
イメージとしては次のような形になっています(実際のコードから簡略化):
# activesupport/lib/active_support/cache/redis_cache_store.rb
FAILSAFE_ERRORS = [
::Redis::BaseError,
# 既存のConnectionPool::Errorなど...
].tap do |errors|
# redis 5.x 以降でトップレベルに RedisClient がある場合のみ追加
errors << ::RedisClient::Error if defined?(::RedisClient::Error)
end.freeze
def failsafe(return_value = nil)
yield
rescue *FAILSAFE_ERRORS => error
handle_exception(error) # ログ等
return_value
endこれにより、
- redis-rb 5.x 系 (
RedisClientがトップレベルに存在する)RedisClient::Errorも確実に rescue される
- redis-rb 4.x 系 (
RedisClientがトップレベルに存在しない)defined?(::RedisClient::Error)が false になるため、互換性を壊さない
という挙動になります。
2-2-2. テストの追加
新しいテスト FailureSafetyFromRedisClientErrorTest が redis_cache_store_test.rb に追加されています。
目的は「RedisClient::Error が発生した場合でも、RedisCacheStore の全公開メソッドがフェイルセーフに動作すること」を保証することです。
テストの構成:
RedisClientErrorRedisClientというテスト用ダミーのクライアントクラスを定義ensure_connectedで強制的にRedisClient::Errorを raise するself.translate_error!を「何もしないで再送出する」no-op 実装にオーバーライド- redis-rb 現行実装では
Redis::Client#call_vとRedis#send_commandの2段階でRedisClient::ErrorをRedis::BaseErrorなどに変換する - 両方とも
Client.translate_error!を「定義位置のClient」として参照する(lexical lookup) - テスト内で
Redis.const_set(:Client, …)のように差し替えているため、見ているtranslate_error!はこのダミークラスになる - そこで no-op にすることで、「
RedisClient::Errorが最後まで変換されずに飛んでくる状況=Sentinel 経由の実際のパス」を忠実に再現している
- redis-rb 現行実装では
- その上で
FailureSafetyBehaviorという既存のテストスイートを再利用し、以下のような各メソッドについてread,write,fetch,delete,increment,decrement,clearなど
- ドキュメント通りの「フェイルセーフな戻り値」(例:
readはnil、incrementはnil/ 既定値、など) を返すことを検証
テストのパターン自体は、既存の
FailureSafetyFromUnavailableClientTestFailureSafetyFromMaxClientsReachedErrorTest
と同じ構造になっており、それの RedisClient::Error 版が追加された形です。
2-2-3. CHANGELOG の更新
activesupport の CHANGELOG に、RedisCacheStore のフェイルセーフが RedisClient::Error を拾うようになったことが追記されています。
バグフィックスとしての振る舞い変更があるため、ライブラリアップデート時に利用者が把握しやすくなっています。
- 影響範囲・注意点
3-1. 影響を受ける構成
特に影響が大きいのは次のような構成です:
ActiveSupport::Cache::RedisCacheStoreを使用しているRedis.new(sentinels: ...)/ Sentinel 構成、またはclient_implementation: RedisClientを使うような設定- 一時的な Redis 障害時に、
Rails.cache.read/Rails.cache.writeなどの呼び出しでRedisClient::ConnectionError,RedisClient::ReadTimeoutError等がアプリケーション側に伝播していた
このPR適用後は、これらの例外はフェイルセーフにより握りつぶされ、従来のドキュメント通り:
- 例外を投げず
- 「キャッシュミス」または「書き込み失敗だが無視」という扱い
に統一されます。
3-2. 例外ハンドリング・監視の観点での注意
- これまで、
RedisClient::Errorをアプリ側で rescue していた / エラートラッキングに出ていた場合- 今後は
Rails.cache呼び出しからその例外は飛んでこなくなります - Rails 側のログ (
handle_exception相当) や Redis 側のメトリクスで監視する必要があります
- 今後は
- 「Redis エラーをあえてアプリケーションエラーとして扱いたい」という設計には合致しません
RedisCacheStoreはあくまで「キャッシュは落ちてもアプリを落とさない」設計であり、その契約に仕様を合わせた変更です- もし Redis 障害を致命的とみなしたい場合は、
RedisCacheStoreではなくアプリ側で直接 Redis クライアントを扱う等、別設計が必要です
3-3. 互換性
- redis-rb 4.x:
RedisClient定数が存在しないため、この変更は実質無影響です(FAILSAFE_ERRORSに追加されない)
- redis-rb 5.x 以降:
- これまで例外として表に出ていた
RedisClient::Error系がフェイルセーフで抑制されるため、「バグ修正としての挙動変更」が発生しますが、ドキュメントされた期待仕様に揃う形の変更です
- これまで例外として表に出ていた
- 参考情報 (あれば)
- このPRが扱う問題と関連する既存Issue/PR:
- #54432:
ConnectionPool::TimeoutErrorがフェイルセーフをすり抜けていた問題 - #54440 / #54460: 上記問題への対応で、rescue 範囲を広げた変更
- #54432:
RedisCacheStoreの設計思想:- 「キャッシュは落ちてもアプリケーションを落とさない」というポリシーで、Redis 障害時には例外を投げず、ミス扱い / サイレントな書き込み失敗とすることが明示されています
- 本PRは、そのポリシーが Sentinel +
RedisClientパスでも一貫して守られるようにするためのものです
#57502 Handle malformed signed cache payloads gracefully
マージ日: 2026/6/1 | 作成者: @fallintoplace
- 概要 (1-2文で)
Rails のActiveSupport::Cache::Coder#loadが「署名付きキャッシュフレームの形式が壊れている場合」に例外を投げてしまう問題を修正し、他の破損ペイロードと同様に「キャッシュミス(nil)」として扱うようにした PR です。これにより、壊れた署名付きキャッシュがアプリケーションエラーとして表面化するのを防ぎます。
- 変更内容の詳細
問題のパス: 署名付きキャッシュフレーム
ActiveSupport::Cache::Coder は、キャッシュ値を以下のような形で扱います。
- シリアライザ(例:
Marshal) - 圧縮器(例:
Zlib) - 署名付きフレーム形式(ActiveSupport が内部で使う独自フォーマット)
Coder#load は、ペイロードが署名付きキャッシュフレームのプレフィックスを持っているとき、ヘッダ部分を String#unpack1 で解析していました。しかし、ヘッダを展開する前後が例外保護されておらず、バイト列が短すぎる/形式が崩れていると次のような例外が外に漏れていました。
coder = ActiveSupport::Cache::Coder.new(Marshal, Zlib)
coder.load("\x00\x11".b)
# => ArgumentError: @ outside of string本来であれば「壊れたキャッシュ → 読み込めない → nil を返す(キャッシュミス扱い)」という動作に統一されるべきところが、署名付きパスだけが例外をそのまま投げてしまう状態でした。
何をしたか
署名付きヘッダのパース処理を例外から保護
- 署名付きフレームのヘッダ(
unpack1)を扱うコードを、他のデシリアライズエラー処理と同じように例外ハンドリングの中に入れています。 - ヘッダが読み取れない・短すぎる・不正なバイト列などで
ArgumentError等が発生しても、内部で捕捉してnilを返すようにした、という挙動に変更されています。 - つまり、「署名はついているがフレームが壊れていて LazyEntry すら作れない」ケースでも、キャッシュミスと同じ扱いに統一されます。
- 署名付きフレームのヘッダ(
テストの追加・回帰テスト
- 既存の「破損したペイロード」のテストケースに、「署名付きだがあまりにも短い(ヘッダが読めない)」ケースを追加しています。
- これにより、今後のリファクタで再び同様の例外が外に漏れるような変更が入った場合に検知できるようになっています。
テストコマンド:
BUNDLE_WITHOUT=db bundle exec ruby -w -Itest test/cache/cache_coder_test.rb- 影響範囲・注意点
影響範囲
ActiveSupport::Cacheを利用しているすべての箇所に潜在的な影響がありますが、挙動の方向性としては「過去に例外を投げていたケースを、キャッシュミスとして扱う」方向の緩和的変更です。- 特に、以下のような環境・ケースで影響を受ける可能性があります:
- 署名付きキャッシュ(Rails 6 以降のデフォルトキャッシュ形式など)を使っている
- キャッシュストアに何らかの理由で「壊れた/部分的な」バイト列が保存される可能性がある(バージョンアップ、キャッシュ共有、データ破損など)
- これまで
ArgumentError: @ outside of stringのような例外が時々発生していた
動作上の変化
- 従来: 壊れた署名付きフレーム →
Coder#load内で例外 → アプリ側に例外が伝播(キャッシュ読み込み時の例外として表面化) - 変更後: 壊れた署名付きフレーム → 例外を内部で捕捉 →
nilを返す → キャッシュミスと同じ挙動 - そのため、「壊れたキャッシュを検知したい」「あえて例外を発生させて監視している」といった特殊な運用をしていない限り、アプリケーションのロジックに悪影響はなく、むしろ安定性が向上します。
- 従来: 壊れた署名付きフレーム →
注意点
- これまでは例外により気付きやすかった「キャッシュデータの破損」が、静かにキャッシュミスとして扱われるようになります。
- キャッシュ破損を監視したい場合は、
ActiveSupport::Cache::Storeのラッパーを作成してログを仕込むなど、別途アプリ側で監視を実装する必要があります。 - 本変更は
ActiveSupport::Cache::Coderの内部挙動レベルの変更であり、通常の API (Rails.cache.read等) のインターフェースは変わりません。
- 参考情報 (あれば)
- 対象クラス:
ActiveSupport::Cache::Coder- 署名付きフレーム形式や
LazyEntryは、Rails 内部のキャッシュ最適化に関わる仕組みです。
- 署名付きフレーム形式や
- 類似挙動:
- 既に「未署名/レガシー形式のペイロード」については、デシリアライズエラーをキャッシュミスとして扱う処理があり、今回の変更はそのポリシーに「署名付きフレームパス」を揃えたものです。
- 実際に問題を踏んでいるか確認するには:
- アプリケーションログやエラートラッカーで
ActiveSupport::Cache::Coder#load起点のArgumentError: @ outside of stringなどを検索し、本 PR 適用後にそれが収まるかを確認できます。
- アプリケーションログやエラートラッカーで
#57511 Test to_sentence with an empty array
マージ日: 2026/6/1 | 作成者: @hammadxcm
概要 (1-2文で)
ActionView のto_sentenceが「空配列に対しては空のhtml_safeな文字列を返す」ことを確認するテストが追加された PR です。プロダクションコードの変更は一切なく、テストコードのみの追加です。変更内容の詳細
対象メソッド
ActionView::Helpers::TextHelper#to_sentence(正確には、Array#to_sentenceを ActionView が拡張したもの)- 既に以下のケースはテスト済みだった:
- 要素数 1 の配列
- 要素数 2 の配列
- 要素数 3 以上の配列
- 今回、新たに「要素数 0(空配列)」のケースがテストに追加された。
追加されたテストのポイント
厳密なコードはPR本文には載っていませんが、意図としては以下のようなテストがactionview/test/template/output_safety_helper_test.rbに 6 行ほど追加されています。想定されるテストイメージ(擬似コード):
rubydef test_to_sentence_with_empty_array_returns_empty_html_safe_string array = [].to_sentence assert_equal "", array assert_predicate array, :html_safe? endもしくは既存の output_safety 系テストに寄せた書き方として:
rubydef test_to_sentence_with_empty_array_is_html_safe result = [].to_sentence assert result.html_safe? assert_equal "", result endいずれにせよ、以下の2点を確認するテストが追加されています。
- 空配列に
to_sentenceを呼ぶと、空文字列 ("") が返る - その返り値は
html_safe扱いになる(ActiveSupport::SafeBuffer)
- 空配列に
- 影響範囲・注意点
実行時の挙動への影響:
- 本PRはテスト追加のみで、ライブラリ本体のコードは変更されていません。
- そのため、既存のアプリケーションの挙動は一切変わりません。
既存仕様の明文化:
- 空配列に対する
to_sentenceは空のhtml_safeな文字列を返す、という仕様がテストによって保証されました。 - これにより、将来の変更でうっかり
nilを返したり、非html_safeなStringを返すようなリグレッションが発生しにくくなります。
- 空配列に対する
開発者視点での注意点:
- ビューで
[].to_sentenceを使った場合、単に何も表示されない(空文字)だけでなく、XSSサニタイズ済みの安全なバッファとして扱われることが保証されます。 rawやhtml_safeを二重に呼ぶ必要はありません([].to_sentence.html_safeは不要)。- この仕様に依存しているコード(例:
content_tagにそのまま渡す、safe_joinと組み合わせる等)は、今後も同じ挙動が継続することがテストで担保されます。
- ビューで
- 参考情報 (あれば)
関連ドキュメント:
ActionView::Helpers::TextHelper#to_sentence
https://api.rubyonrails.org/classes/ActionView/Helpers/TextHelper.html#method-i-to_sentenceActiveSupport::SafeBuffer(html_safe文字列について)
https://api.rubyonrails.org/classes/ActiveSupport/SafeBuffer.html
類似テスト:
- 同ファイル
output_safety_helper_test.rbには、to_sentenceの要素数 1, 2, 3+ のケース、その他html_safeに関する挙動テストが既に含まれており、今回の追加はそのカバレッジ補完という位置づけです。
- 同ファイル
#57524 [ci skip] Update Ruby version requirement to 3.4 or newer in the Getting started guide
マージ日: 2026/6/1 | 作成者: @paul-louyot
- 概要 (1-2文で)
Rails公式ガイド「Getting Started」で推奨するRubyバージョンが「3.4以上」に引き上げられました。これにより、チュートリアル内でRuby 3.4以降で追加されたit構文を前提としたコード例を維持できるようにすることが目的です。
- 変更内容の詳細
- 対象ファイル:
guides/source/getting_started.md - 変更内容は1行のみで、ガイド内の「使用するRubyの推奨バージョン」の記述を更新しています。
例(イメージ):
- You will need Ruby 3.2 or newer...
+ You will need Ruby 3.4 or newer...PRの説明によると、この更新は以下を意図しています。
- Getting Started ガイド内では、新しいRubyのブロック内
it構文(例:パターンマッチなどの新構文)を使ったサンプルが含まれている。 - Rubyのバージョン要件が古いままだと、読者がそのままサンプルを実行した際にエラーになる可能性がある。
- チュートリアルのサンプルコードと、公式に案内するRubyバージョンを整合させるため、「Ruby 3.4以上」を必須(あるいは強く推奨)とする。
「it syntax inside blocks」という表現から、Ruby 3.4で導入された新構文(ブロック内のよりモダンな書き方)を前提としたチュートリアルコードを維持するための変更であると読み取れます。
- 影響範囲・注意点
- 対象はドキュメントのみで、Rails本体のコードや挙動には変更はありません。
- Getting Started ガイドに従って新規に環境構築するユーザー:
- Ruby 3.4未満だと、ガイドのコード例がそのままでは動作しない可能性があります。
- ガイドに沿う場合は、少なくともRuby 3.4へのアップグレードが推奨されます。
- 既存プロジェクト:
- 既にRuby 3.2や3.3などでRailsを使っているプロジェクトに対する強制的な変更ではありません。
- ただし、ガイドにある最新の構文を利用したい場合は、Ruby本体のバージョンアップを検討する必要があります。
- Railsのサポートポリシー:
- ガイドのRuby要件が3.4以上となることで、「Rails入門」を目的としたユーザーは、比較的新しいRubyを使うことが事実上の前提になります。
- CI設定などでRuby 3.4をまだ追加していない場合は、教育用途のサンプルや社内向けチュートリアルを更新する際にバージョン整合を取る必要があります。
- 参考情報 (あれば)
- 元Issue:
Fixes #57513
→ ガイドと実際の推奨Rubyバージョン・構文の不整合を解消するためのIssueと思われます。 - 変更種別: ドキュメントのみ (
[ci skip]がタイトルに付与されており、CI不要の軽微変更であることを示唆) - この変更は、Railsで新規に学習・開発を始めるユーザーが、Ruby 3.4の新構文を前提とした最新チュートリアルに沿いやすくするための調整と位置づけられます。
#57460 Fix Mysql2Adapter#discard! corrupting parent connection after fork
マージ日: 2026/6/1 | 作成者: @yahonda
- 概要 (1-2文で)
MySQL 用アダプタMysql2Adapter#discard!が、fork後の子プロセス終了時に親プロセス側の MySQL コネクションを壊してしまう不具合を修正する PRです。MYSQL_PREPARED_STATEMENTS=trueかつ mysql2 アダプタ使用時に、子プロセス終了時の finalize が親のソケットに書き込めないよう、子側のソケット FD を/dev/nullに差し替えるようにしました。
- 変更内容の詳細
問題の背景
MYSQL_PREPARED_STATEMENTS=trueの場合、Mysql2Adapterは SQL →Mysql2::Statementのキャッシュ(@statements)を持つ。forkすると、- MySQL クライアントのソケット FD(
Mysql2::Clientが内部で使うソケット) - そのクライアントに紐づく Ruby オブジェクト(
Mysql2::Statement等) が子プロセスに「コピー」される(FD は同じ番号を指す共有リソース)。
- MySQL クライアントのソケット FD(
既存コードでは、子プロセス側で
Mysql2Adapter#discard!を呼ぶときにclient.automatic_close = falseだけ行い、親とソケットを共有しているMysql2::Clientを「自動クローズしない」ようにする対応のみ行っていた。
しかし、
@statementsにぶら下がっているMysql2::Statementオブジェクトは、そのまま子プロセスのヒープ上に残る。- 子プロセスが
exitすると、そのStatementオブジェクトの finalizer が走り、COM_STMT_CLOSEをソケット FD に書き込む。 - そのソケット FD は親プロセスと共有しているため、その書き込みはサーバ側から見ると「まだ接続を使っているはずの親プロセスから
COM_STMT_CLOSEが飛んできた」ことになり、サーバがコネクションを閉じる。 - 結果として、親プロセス側が次のクエリを投げると
Lost connection to MySQL server during queryで落ちる。
- 子プロセスが
テスト上では test_forked_child_doesnt_mangle_parent_connection が MYSQL_PREPARED_STATEMENTS=true + mysql2 の構成で失敗し始めていた、というのが直接の発端です。
修正内容
Mysql2Adapter#discard! を、PostgreSQL アダプタの discard! と同じ方針に合わせて修正し、子プロセス側のソケット FD を /dev/null に付け替えるようにしました。
イメージとしては PostgreSQL 側でやっているこれと同じことを、mysql2 でも行います:
@raw_connection&.socket_io&.reopen(IO::NULL) rescue nilmysql2 の場合も同様に、子プロセスで discard! が呼ばれた段階でソケット IO を IO::NULL に reopen することで、
- 子プロセス内の
Mysql2::Statementオブジェクトの finalizer が走ってCOM_STMT_CLOSEを「送ろうとしても」、 - その書き込み先は
/dev/nullになっているため、実際の MySQL サーバには届かない
という状態になります。
これにより、親プロセスが保持している「本物の」コネクションは安全に維持されます。
PR 説明にある再現コードの意味
PR に記載の再現スクリプトは、Rails を介さずに問題そのものを説明しています。
重要なポイントだけ抜き出すと:
client = Mysql2::Client.new(...)
# prepared statement を作ってキャッシュに溜めるイメージ
statements = {}
statements["sql1"] = client.prepare("SELECT 1")
statements["sql1"].execute.to_a
statements["sql2"] = client.prepare("SELECT 2")
statements["sql2"].execute.to_a
pid = fork do
# 子では元の client を自動クローズしないようにする
client.automatic_close = false
# 子は新しく別 client を作ってクエリして exit
Mysql2::Client.new(...).query("SELECT 1").to_a
exit
end
Process.waitpid pid
# 親で再度クエリしようとすると接続ロスト
client.query("SELECT 1")
# => Mysql2::Error::ConnectionError: Lost connection to MySQL server during query子プロセス終了時に、statements で保持している Mysql2::Statement の finalizer が動き、その COM_STMT_CLOSE が親と共有している FD に書かれることで、親の接続が壊れる、という構造になっています。
ここで子側で FD を /dev/null に reopen しておけば、最後の client.query("SELECT 1") が成功するようになる、というのが修正の妥当性の確認です。
- 影響範囲・注意点
対象となるのは:
mysql2アダプタ使用時MYSQL_PREPARED_STATEMENTS=trueの構成forkを使うアプリケーション(例: prefork 型のアプリサーバ、バックグラウンドワーカーなど)
上記条件下で、
- 既存のテスト
test_forked_child_doesnt_mangle_parent_connectionが通るようになり、 - 子プロセスが
discard!を呼んだ後に exit しても、親プロセスの接続が壊れないことが保証されます。
- 既存のテスト
挙動としての変更点:
- 子プロセス側で
discard!が実行された後は、そのプロセスからは元の MySQL ソケットへは一切書き込みが行われなくなります(書き込み先は/dev/null)。 - これは「子は親のコネクションを使わない」という設計と一致しており、Rails 的には期待される動作です。
- 子プロセス側で
既存テスト:
- mysql2 アダプタに関する connection-adapter テスト一式 (178 tests)
forkとdiscard!関連の mysql2 テスト (6 tests) がすべてグリーンであることが確認されています。
アプリケーション開発者視点では、「fork する環境で mysql2 + prepared statements を有効にしていても、親コネクションが子終了時に壊れることはなくなった」と理解しておけば十分です。特別な設定変更は不要です。
- 参考情報 (あれば)
- 類似実装:
PostgreSQLAdapter#discard!@raw_connection&.socket_io&.reopen(IO::NULL) rescue nilによって、同様に子プロセス側で FD を/dev/nullに向けている。 - 関連コミット:
- fork セーフティを導入した過去コミット: f32cff5563
- CI での最初の検出:
#57509 Test ImmutableString custom boolean options and serialize
マージ日: 2026/6/1 | 作成者: @hammadxcm
- 概要 (1-2文で)
ActiveModel::Type::ImmutableStringに関して、ドキュメントされている挙動(boolean 用のtrue:/false:オプションや、boolean / 数値 / symbol の文字列化)が実際に保証されるよう、テストを追加した PR です。プロダクションコードの変更はなく、テストコードのみの追加です。
- 変更内容の詳細
対象ファイル:
activemodel/test/cases/type/immutable_string_test.rb(+20/-0)
この PR で追加されたテストは、主に以下の点をカバーします。
(1) true: / false: オプション付きコンストラクタのテスト
ActiveModel::Type::ImmutableString は boolean 値のキャスト・シリアライズ時に、コンストラクタのオプションで文字列表現をカスタマイズできますが、その挙動がテストされていませんでした。
例(イメージコード):
type = ActiveModel::Type::ImmutableString.new(true: "YES", false: "NO")
type.cast(true) # => "YES"
type.cast(false) # => "NO"
type.serialize(true) # => "YES"
type.serialize(false) # => "NO"この PR では、上記のような true: / false: オプションを指定した場合に:
cast(true)/cast(false)serialize(true)/serialize(false)
の両方が指定した文字列に変換されることをテストで検証しています。
(2) デフォルトの boolean 文字列表現 "t" / "f" のテスト
オプションを指定しない場合のデフォルト挙動として:
type = ActiveModel::Type::ImmutableString.new
type.serialize(true) # => "t"
type.serialize(false) # => "f"となることがドキュメントされています。この PR は、このデフォルトの "t" / "f" という表現が確実に保証されるようテストを追加しています。
(3) 数値 / symbol の serialize のテスト
ImmutableString の serialize は、boolean 以外にも数値や symbol を受け取った場合に文字列化する分岐を持っていますが、その部分もこれまでテストされていませんでした。
テスト対象となる典型的な挙動のイメージ:
type = ActiveModel::Type::ImmutableString.new
type.serialize(123) # => "123"
type.serialize(3.14) # => "3.14"
type.serialize(:status) # => "status"これらのパスについてもテストを追加し、数値や symbol が期待どおり String に変換されることを確認しています。
- 影響範囲・注意点
影響範囲:
- プロダクションコードには一切変更がないため、挙動の変更や既存アプリへの影響はありません。
ActiveModel::Type::ImmutableStringの既存仕様(特に boolean, 数値, symbol の取り扱い)がテストでカバーされるようになり、将来的なリファクタリングや仕様変更時に意図しない互換性破壊が検出されやすくなりました。
注意点:
- 既にドキュメントに書かれている仕様をテストで「固定」している形になるため、今後
"t"/"f"以外のデフォルト表現に変えたい、あるいは boolean / 数値 / symbol の扱いを変えたい場合は、テストの更新と互換性への配慮が必要になります。 - カスタムの
true:/false:オプションに依存しているコードを書いている場合、その仕様が今後も守られることがテストで担保されるようになったと理解しておくとよいです。
- 既にドキュメントに書かれている仕様をテストで「固定」している形になるため、今後
- 参考情報 (あれば)
関連クラス:
ActiveModel::Type::ImmutableString- ActiveModel の型システムの一部で、
Stringを freeze(不変化)して扱うための型クラス。 - boolean, 数値, symbol を受け取った際にどのように文字列へ変換するかが、今回テストで明示的に押さえられました。
- ActiveModel の型システムの一部で、
想定ユースケース:
- Postgres の
textカラムを immutable に扱いたい場合や、ActiveRecord の attribute API でattribute :foo, :immutable_stringのように定義して boolean / 数値 / symbol を一貫した文字列表現にマッピングしたいケースなどで、この仕様とテストの存在が意味を持ちます。
- Postgres の
#57510 Test Float casting of Infinity and NaN strings
マージ日: 2026/6/1 | 作成者: @hammadxcm
- 概要 (1-2文で)
Rails のActiveModel::Type::Floatが"Infinity","-Infinity","NaN"を正しくFloat::INFINITY,-Float::INFINITY,Float::NANにキャストできることを確認するテストが追加されました。
本PRはテストコードのみの変更で、本番コードの挙動変更はありません。
- 変更内容の詳細
対象ファイル:
activemodel/test/cases/type/float_test.rb(+7行)
すでにドキュメント上は以下の仕様が記載されていました:
"Infinity"→Float::INFINITY"-Infinity"→-Float::INFINITY"NaN"→Float::NAN
しかし、この挙動を直接検証するテストが存在していなかったため、今回それらを明示的に検証するテストが追加されました。
おおまかには、以下のような内容のテストが追記されています(イメージコード):
def test_casts_special_float_strings
type = ActiveModel::Type::Float.new
assert_equal Float::INFINITY, type.cast("Infinity")
assert_equal(-Float::INFINITY, type.cast("-Infinity"))
assert_predicate type.cast("NaN"), :nan?
endポイント:
"Infinity"と"-Infinity"は通常の==比較で検証。NaNはNaN == NaNがfalseになるため、nan?メソッドなどで検証する形になっているはずです。
- 影響範囲・注意点
影響範囲:
- 追加されたのはテストのみであり、既存のキャスティングロジック(
ActiveModel::Type::Float#cast)自体には変更がありません。 - 既に
"Infinity","-Infinity","NaN"を前提にコードを書いていたアプリケーションに対して挙動変更はありません。
- 追加されたのはテストのみであり、既存のキャスティングロジック(
注意点:
- これらの文字列を DB カラム(float/decimal)に対する入力値として使う場合、ORM レイヤーでは
Floatになるものの、DB ドライバや DB 側がInfinityやNaNを受け付けるかは別問題です(DBごとの仕様に依存)。 - テストが入ったことで、将来的なリファクタリングで
"Infinity","-Infinity","NaN"のサポートが壊れた場合には速やかに検知されるようになりました。
→ これらの文字列を利用している場合、挙動が暗黙に変わるリスクが減ります。
- これらの文字列を DB カラム(float/decimal)に対する入力値として使う場合、ORM レイヤーでは
- 参考情報 (あれば)
- 対象クラス:
ActiveModel::Type::Float- ActiveModel の型キャスティング機能で、フォーム入力やパラメータ等を Ruby の
Floatに変換する役割を持つクラスです。
- ActiveModel の型キャスティング機能で、フォーム入力やパラメータ等を Ruby の
- Ruby の
Float特殊値:Float::INFINITY/-Float::INFINITYFloat::NAN(nan?で判定)
#57523 Fix typos and grammar in Form Helpers docs
マージ日: 2026/6/1 | 作成者: @VladNegara
概要 (1-2文で)
Action View の Form Helpers ガイド (guides/source/form_helpers.md) に含まれていた英語のタイポや文法ミスを修正するドキュメント専用のPRです。コードや挙動には一切手を加えておらず、ガイドの読みやすさ・正確さを向上させる内容です。変更内容の詳細(あればサンプルコードも含めて)
- 対象:
guides/source/form_helpers.mdのみ(+5/-5 行) - 内容の種類:
- 単純なスペルミスの修正
- 例: “recieve” → “receive”、“extenstion” → “extension” のような誤記修正
- 文法の調整
- 主語と動詞の不一致、冠詞 (a/the) の抜けや過多、前置詞の誤りなどを自然な英語に修正
- 長すぎて読みにくい文を適切に区切る、あるいは語順を入れ替えて読みやすくする
- 用語・言い回しの統一
- 既存の Rails ガイド全体の用語スタイルに合わせた表現へ微調整(例: “form helper” の単数/複数の揺れを統一、同じ概念に対する別表現を統一 など)
- 単純なスペルミスの修正
サンプルコードのロジックや API の説明内容自体は変えておらず、「文中の英語」だけが対象です。そのため、form_with, form_for, fields_for などの使い方やサンプルコードは従来どおりで、説明の語句がより正確・自然になっている、というタイプの修正です。
- 影響範囲・注意点
- 影響範囲:
- 実装コードには一切変更がないため、アプリケーションや既存コードへの影響はありません。
- 影響するのは、英語版の Rails ガイドを読む開発者のみです。
- 注意点:
- 「挙動の変更」や「推奨パターンの変更」は含まれていないため、これを理由にアプリケーションの修正を行う必要はありません。
- 実装や API の仕様が変わったわけではないので、Changelog というより「ドキュメントの品質向上」として理解しておけば十分です。
- 参考情報 (あれば)
- 対象 PR: https://github.com/rails/rails/pull/57523
- 修正対象のガイド:
- Action View Form Helpers ガイド (
guides/source/form_helpers.md)
- Action View Form Helpers ガイド (
- CI は
[ci skip]が指定されているため走っておらず、完全にドキュメント専用の変更であることが明示されています。
#57525 JSONGemCoderEncoder: serialize non-String keys with to_s instead of as_json
マージ日: 2026/6/1 | 作成者: @byroot
- 概要 (1-2文で)
Rails の ActiveSupport における JSONGemCoderEncoder が、ハッシュの非文字列キーをas_jsonではなくto_sでシリアライズするように戻されました。これにより、従来の JSON エンコーダと同じ振る舞いになり、#57520 で導入された挙動変更による非互換を解消します。
- 変更内容の詳細
何が変わったか
対象: ActiveSupport::JSON::Encoding::JSONGemCoderEncoder(ActiveSupport::JSON.encode などで使われるエンコーダ)
以前 (問題のあった状態)
ハッシュのキーが String 以外(シンボル、数値、オブジェクトなど)の場合、キーに対して
as_jsonが呼ばれていました。例:
rubyh = { foo: 1, 42 => 2 } # キー :foo / 42 に対して as_json が使われる
今回の PR での修正
- 非 String キーに対して
as_jsonではなくto_sを呼ぶように変更。 - これは「旧来のエンコーダが行っていた動作」に合わせたものです。
擬似コードイメージとしては、キーの処理が:
# 変更前(問題のあるイメージ)
hash.each_with_object({}) do |(k, v), result|
encoded_key = k.as_json # または JSON エンコードの途中で as_json が使われる
result[encoded_key] = v
endから:
# 変更後(この PR のイメージ)
hash.each_with_object({}) do |(k, v), result|
encoded_key = k.is_a?(String) ? k : k.to_s
result[encoded_key] = v
endのようなロジックに戻されています。
テストの追加
activesupport/test/json/encoding_test.rb に、以下のような振る舞いを検証するテストが追加されています(内容の要旨):
- ハッシュに非 String キー(例: シンボル、数値、独自オブジェクト)を含めて JSON エンコードしたとき、
- キーが
to_sで文字列化されること as_jsonによる予期せぬ変換を行わないこと
- キーが
- 影響範囲・注意点
影響範囲
- JSON エンコード時のハッシュのキーが対象です。
- 特に以下のようなコードを使っている場合に関係します:
ActiveSupport::JSON.encode(hash)hash.to_json(内部で同じエンコーダを使うケース)
- Rails の JSON レスポンスなどで、ハッシュをそのまま返している場合も間接的に影響します。
実務的な影響
互換性の回復 (後方互換)
- 旧来のエンコーダは非 String キーを
to_sしていたため、それと同じ挙動に「戻る」変更です。 - 直前の PR #57520 によって挙動が変わり、
as_jsonが呼ばれるようになったことで:- キーの型に依存した複雑な
as_json実装を持つアプリで、予期しないキー変換・エラーが発生していた可能性があります。
- キーの型に依存した複雑な
- 今回の PR により、その不意の互換性破壊が解消されるため、多くのアプリにとっては「バグ修正」であり、望ましい変更です。
- 旧来のエンコーダは非 String キーを
as_jsonに依存したキー変換をしていた場合- もし一時的に(あるいは意図的に)「キー側の
as_jsonが呼ばれる」ことを前提に実装していた場合、- この PR により、その前提は再び無効になります。
- 具体的には:
- 「キー側の
as_jsonをオーバーライドして JSON のキー表現を制御する」ようなトリッキーな実装は動かなくなります。
- 「キー側の
- 一般的には推奨されない使い方なので、多くのアプリには無関係ですが、該当する場合は注意が必要です。
- もし一時的に(あるいは意図的に)「キー側の
JSON のキーは文字列になる前提が明確に
- JSON 仕様上もオブジェクトのキーは文字列であるため、
- 「非 String キーは
to_sして文字列キーにする」という挙動は自然で、ライブラリ作者・クライアント実装側ともに扱いやすい挙動です。
- 参考情報 (あれば)
- 該当 PR:
- #57525: JSONGemCoderEncoder: serialize non-String keys with
to_sinstead ofas_json
- #57525: JSONGemCoderEncoder: serialize non-String keys with
- 修正対象の直前の PR:
- #57520: 挙動を変更し、非 String キーに
as_jsonを使うようにしてしまった PR(今回の修正でその部分が元に戻された)
- #57520: 挙動を変更し、非 String キーに
- 関連する観点:
- JSON のオブジェクトキーは文字列が前提であり、Ruby 側でシンボル・数値・オブジェクトなどをキーにしている場合も、クライアントからは常に文字列キーとして見えることを前提に設計すべきです。
#57521 Fix assert_part / assert_no_part for body parts nested under an attachment
マージ日: 2026/5/31 | 作成者: @55728
- 概要 (1-2文で)
ActionMailer のテスト用アサーションassert_part/assert_no_partが、添付ファイル付きメールで本文パートを正しく検出できない問題を修正した PR です。Mail#partsではなくMail#all_partsを使うことで、multipart/mixed配下にネストされたmultipart/alternative内の text/html パートも検査対象に含めるよう変更されています。
- 変更内容の詳細
問題の背景
ActionMailer::TestCase#assert_part / #assert_no_part(まだ未リリースの新機能)は、メールの MIME パートを検査するためのテスト用ヘルパーです。
しかし、元実装では以下のようなコードでパートを検索していました:
part = [*mail.parts, mail].find { |part| mime_type.match?(part.mime_type) }ここで使っている Mail#parts は「直下の子パートのみ」を返します。
一方、Action Mailer で「text と html の両方の本文 + 添付ファイル」があるメールを送ると、構造は次のようになります:
multipart/mixed ← 最上位
├── multipart/alternative
│ ├── text/plain ← 本文の text パート
│ └── text/html ← 本文の html パート
└── image/png ← 添付ファイルmail.parts で見えるのは multipart/alternative と image/png だけで、その1段下にぶら下がっている text/plain と text/html は見えません。
そのため:
assert_part :html/assert_part :text
→ 実際には存在するのに検出できず、テストが失敗(false negative)。assert_no_part :html/assert_no_part :text
→ 実際にはパートが存在するのに「見えていない」ため、テストが通ってしまう(false positive)。
特にこちらは「html を出さないはず」という保証テストが意味をなさなくなり、危険。
修正内容
Mail#parts ではなく、ネストされたパートも含めてフラットに列挙する Mail#all_parts を使うように修正されています。これは既に ActionMailer::InlinePreviewInterceptor でも使われている手法です。
修正後のコード(概念的には以下のような変更):
# 変更前
part = [*mail.parts, mail].find { |part| mime_type.match?(part.mime_type) }
# 変更後
part = [*mail.all_parts, mail].find { |part| mime_type.match?(part.mime_type) }この変更が assert_part と assert_no_part 両方に適用されています。
これにより、multipart/alternative の下にネストされた本文パートも検索対象に含まれます。
再現用テスト
actionmailer/test/assert_select_email_test.rb に、新しいテストケース AssertMultipartWithAttachmentEmailTest が追加されています。
テスト用メールは以下のように組み立てられます:
attachments["example.png"] = { mime_type: "image/png", content: "PNGDATA" }
mail(...) do |format|
format.text { render plain: options[:text] } if options.key?(:text)
format.html { render plain: options[:html] } if options.key?(:html)
end追加されたテスト:
test_assert_part_finds_body_parts_nested_under_attachmentassert_part :textとassert_part :htmlで、添付ファイルの存在によってネストされた本文パートが正しく見つかることを確認。
test_assert_no_part_detects_body_parts_nested_under_attachment- 対応するパートが存在する状態で
assert_no_partを呼び出したとき、必ず失敗(例外を送出)することを確認。
- 対応するパートが存在する状態で
修正前の main では:
assert_no_partが例外を投げずに通ってしまう(false positive)assert_partがパートを見つけられない(false negative)
という想定通りの「赤」を再現し、修正後はテストファイル全体がグリーンになることが確認されています。
- 影響範囲・注意点
影響範囲:
ActionMailer::TestCase#assert_part/#assert_no_partの挙動のみ(いずれも未リリース API)。- 実際のメール送信や本番環境でのメール構造には影響しません。
- Action Mailer で MIME パートを扱う既存コードのうち、
InlinePreviewInterceptorは既にall_partsを使っており、本 PR と整合的です。
注意点:
- もともとの実装では「トップレベルのパートだけを対象にする」という仕様にも読み取れるが、添付ファイルを追加しただけで本文パートが検査できなくなるのは、テスト API として直感的でなく危険な挙動です。この PR により、開発者の期待に近い「メール全体の MIME パートを対象に検査する」動きに揃えられます。
- API は 8.2.0.alpha 時点で未リリースのため、既存アプリの互換性リスクはほぼゼロです。リリース前に false positive/negative を潰せた形になります。
- 参考情報 (あれば)
- 対象メソッド:
ActionMailer::TestCase#assert_partActionMailer::TestCase#assert_no_part
- 関連 PR:
- 元機能追加: #55348 (
assert_part/assert_no_part追加)
- 元機能追加: #55348 (
- 類似の
all_parts利用箇所:ActionMailer::InlinePreviewInterceptor(inline_preview_interceptor.rb:56)
#55555 Rate limiting calls cache_key on by: if the object responds to it
マージ日: 2026/5/31 | 作成者: @daniel-sabourin
- 概要 (1-2文で)
Rails のActionController::RateLimitingにおいて、rate_limitのby:オプションに任意オブジェクトを渡せるようにし、そのオブジェクトがcache_keyを実装していればそれを自動的に使ってレート制限用のキャッシュキーを生成できるようにした PR です。これにより、IP アドレスではなく「ユーザーごと」など、より柔軟な単位でのレート制限が行いやすくなります。
- 変更内容の詳細
背景・モチベーション
- これまでの Rails のレートリミットは、デフォルト設定では
remote_ipに基づくケースが多く、「同じネットワーク(同じ IP)からの複数ユーザー」がいる環境では不都合が出ることがある。 - 認証済みユーザーがいる場合、IP 単位ではなく「ユーザー単位」でレートリミットをかけたいが、その際に毎回
cache_keyを明示的に呼ぶのは煩雑。 - Active Record オブジェクトなど「
cache_keyを持つオブジェクト」をそのままby:に渡せれば、コントローラ側の記述をシンプルにできる。
実際の仕様変更
変更前(従来の書き方の例)
「ユーザーごとのレートリミット」をしたい場合は、自分で cache_key を呼び出す必要がありました。
class CommentsController < ActionController::API
# キャッシュキー: "rate-limit:comments:user/1" (current_user の cache_key )
rate_limit to: 2, within: 2.seconds, by: { current_user.cache_key }
private
def current_user
User.find(1)
end
end変更後(この PR で可能になる書き方)
by: に任意のオブジェクトを渡せるようになり、そのオブジェクトが cache_key に応答する場合は内部で cache_key が呼ばれます。
class CommentsController < ActionController::API
# キャッシュキー: "rate-limit:comments:user/1" (current_user.cache_key が内部的に呼ばれる)
rate_limit to: 2, within: 2.seconds, by: { current_user }
private
def current_user
User.find(1)
end
endポイント:
by:に渡されたオブジェクトがrespond_to?(:cache_key)の場合、そのcache_keyを使ってストア上のキャッシュキーが組み立てられるようにrate_limiting.rbが修正されています。cache_keyを持たないオブジェクトや、文字列・数値などは従来どおりの取り扱い(=そのままキーに使われるか、既存のロジックに従う)と推測されます。- テスト (
rate_limiting_test.rb) が追加・更新され、cache_key対応オブジェクトをby:に渡したときの挙動がカバーされています。 actionpack/CHANGELOG.mdにこの挙動変更が追記されています。
- 影響範囲・注意点
既存コードへの互換性
- 既存で
by: { current_user.cache_key }のように書いているコードは、そのまま動作します(挙動は変わりません)。 by:にオブジェクトを渡していて、かつそのオブジェクトがすでにcache_keyを持っていた場合は、今回の変更により「cache_keyが自動で使われる」ようになります。
ただし、これまでにそういった使い方をしていたケースは少ないと考えられ、互換性リスクは比較的小さいと思われます。
- 既存で
キャッシュキーの安定性
cache_keyは通常 Active Record モデルではmodel_name/id-更新時刻形式になり、更新のたびに変わることがあります。- レートリミットの粒度として「ユーザーごと」で安定したキーを持ちたい場合、
Userモデルのcache_keyが更新タイミングで変わることを理解しておく必要があります(通常はそれでも「同じユーザー」であればキーは論理的には同じドメインに属するので問題になりづらいですが、モデル定義によっては注意が必要)。 - 完全に安定したキー(例:
user:<id>固定)を使いたい場合は、ユーザー側でcache_keyを上書きするか、従来どおりby: { "user:#{current_user.id}" }といった文字列を明示する選択肢もあります。
パフォーマンス
cache_keyを呼ぶコストが追加されますが、多くのアプリでは無視できる程度です。大量リクエストでレートリミットが多用されるケースでは、cache_keyの実装が高コストでないかを確認しておくと安心です。
設計上のメリット
- 認証ユーザー単位や、テナント単位など、
cache_keyを実装したドメインオブジェクトごとにレートリミットを簡潔に適用できるようになり、「IP 単位レートリミットからの脱却」がしやすくなります。 - プロジェクト全体で統一した書き方(
by: { current_user })ができ、cache_key呼び出しを各所にバラバラに散らさなくてよくなります。
- 認証ユーザー単位や、テナント単位など、
- 参考情報 (あれば)
変更ファイル:
actionpack/lib/action_controller/metal/rate_limiting.rbby:で渡されたオブジェクトに対し、respond_to?(:cache_key)チェックを行い、true の場合はcache_keyを用いて内部キャッシュキーを生成するロジックが追加。
actionpack/test/controller/rate_limiting_test.rbcache_keyを持つオブジェクトをby:に渡した場合のレート制限動作を確認するテストが追加。
actionpack/CHANGELOG.mdrate_limitのby:にオブジェクトを渡した際の新挙動が記載。
レートリミットをユーザー単位にしたい場合の典型的な形:
rubyclass ApplicationController < ActionController::API # 例: 各ユーザー 1 分あたり 60 回まで rate_limit to: 60, within: 1.minute, by: { current_user } def current_user # Devise 等で取得 super end end
#55388 Respect schema_search_path on rails dbconsole for Postgres
マージ日: 2026/5/31 | 作成者: @sobrinho
- 概要 (1-2文で)
PostgreSQL を使う際に、rails dbconsoleがconfig/database.ymlのschema_search_path設定をこれまで無視していた問題を修正し、アプリケーションと同じ search_path でpsqlが起動するようにした PR です。Rails の ActiveRecord 接続と手動で使うrails dbconsoleの挙動を揃える変更です。
- 変更内容の詳細
何をしたか
rails dbconsole(アダプタ: PostgreSQL)がpsqlを起動する際に、config/database.ymlのschema_search_pathをPGOPTIONSに反映するようにしました。- 具体的には、
ActiveRecord::ConnectionAdapters::PostgreSQLAdapterの dbconsole 用ロジックで、PGOPTIONS="--search_path=..."を環境変数として付与してpsqlを呼び出すようになっています。 - これにより、Rails アプリ内の接続で使われている search_path と、
rails dbconsoleで開かれるpsqlセッションの search_path が一致します。
イメージ・サンプル
config/database.yml で以下のように search path をカスタムしているケースを想定します:
production:
adapter: postgresql
database: myapp_production
username: myuser
password: secret
schema_search_path: myschema,public従来:
- Rails アプリの DB 接続:
search_path = myschema, public rails dbconsole→psql:- search_path デフォルト:
$user,public - 手動で
SET search_path TO myschema,public;する必要があった
- search_path デフォルト:
変更後:
rails dbconsole実行時、内部的に:PGOPTIONS="--search_path=myschema,public" psql ...
- その結果、
psqlセッションも自動的にmyschema,publicが search_path に設定された状態で開く
コード上のポイント
※PR本文から読み取れる範囲の挙動:
postgresql_adapter.rbで dbconsole 実行用のパラメータを構築する箇所に、schema_search_pathが存在する場合にPGOPTIONSを組み立てる処理が追加されています。- 追加されたテスト (
activerecord/test/cases/adapters/postgresql/dbconsole_test.rb) では、schema_search_pathを設定した接続設定を用意dbconsole用のコマンド・環境変数を生成PGOPTIONSに"--search_path=<schema_search_path>"が含まれていること を検証しています。
activerecord/CHANGELOG.mdにもこの挙動変更(機能追加)が追記されています。
- 影響範囲・注意点
主な影響範囲
- 対象: PostgreSQL を使っていて、かつ
schema_search_pathをconfig/database.ymlで指定している Rails アプリ のrails dbconsole実行時の挙動。 - 今後は
rails dbconsoleで開くpsqlセッションが、Rails アプリと同じ search_path を自動的に利用します。
開発者視点でのポイント
- これまで
rails dbconsole後に毎回SET search_path TO ...を手で打っていた場合、その手順が不要になります。 - 逆に「dbconsole は always デフォルト search_path で開きたい」といった前提のスクリプトや運用がある場合、挙動が変わる点に注意が必要です(例: ログイン直後の search_path を前提にした診断スクリプトなど)。
schema_search_pathが設定されていないプロジェクトには影響しません(従来どおりデフォルトの$user,public)。
環境変数の扱い
- すでに
PGOPTIONSを独自に使っている場合、この PR の実装次第では:- 既存の
PGOPTIONSに--search_path=...を追加する - あるいは
schema_search_pathがあるときにPGOPTIONSを上書きする いずれかになります。
実装上は「上書き」か「追記」かが重要ですが、PR説明文からは主に「search_pathをPGOPTIONSにセットする」ということだけが明示されているため、既存のPGOPTIONS活用をしている場合は、マージ後の挙動を一度確認した方が安全です。
- 既存の
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/55388
- 関連する設定項目:
config/database.ymlschema_search_path: ActiveRecord が PostgreSQL に接続するときのsearch_pathを制御
- PostgreSQL 公式ドキュメント(
PGOPTIONS/search_path):PGOPTIONS環境変数を使うと、psql等クライアント起動時にサーバへのオプション(--search_path=...など)を渡せるsearch_pathはスキーマ解決順序を制御し、テーブル名などの解決対象スキーマを切り替えるために使われる