Keep `HashWithIndifferentAccess#filter` returning indifferent access
Rails 8 で HashWithIndifferentAccess の非破壊的メソッド #filter が Hash を返し、インデファレントアクセスが失われていた問題を解消し、#select と同様に HashWithIndifferentAccess を返すようにしました。
背景
HashWithIndifferentAccess はキーを文字列・シンボルどちらでも参照できる便利なラッパーですが、#filter が Hash#select のエイリアスとして実装されていなかったため、HashWithIndifferentAccess 上で呼び出すと標準の Hash#filter が呼び出され、結果が普通の Hash になるケースがありました。この挙動は以下のようにインデファレントアクセスが失効します。
h = ActiveSupport::HashWithIndifferentAccess.new(a: 1, b: 2)
h.select { |_, v| v > 1 }.class # => ActiveSupport::HashWithIndifferentAccess
h.filter { |_, v| v > 1 }.class # => Hash # 旧挙動
結果として h.filter(... )[:b] が nil になるなど、期待した API 体験が損なわれていました。
技術的な変更
activesupport/lib/active_support/hash_with_indifferent_access.rb に alias_method :filter, :select を追加し、#filter をオーバーライド済みの #select にエイリアスしました。これにより、#filter は内部で dup.tap { |hash| hash.select!(*args, &block) } を実行し、戻り値は常に HashWithIndifferentAccess のインスタンスになります。
@@
def select(*args, &block)
return to_enum(:select) unless block_given?
dup.tap { |hash| hash.select!(*args, &block) }
end
+alias_method :filter, :select
同時にテスト activesupport/test/hash_with_indifferent_access_test.rb が追加され、#filter が正しく HashWithIndifferentAccess を返すことと、キーアクセスがインデファレントに機能することが検証されています。
@@
def test_indifferent_filter
hash = ActiveSupport::HashWithIndifferentAccess.new(@strings).filter { |k, v| v == 1 }
assert_equal({ "a" => 1 }, hash)
assert_instance_of ActiveSupport::HashWithIndifferentAccess, hash
end
設計判断
この修正は 「メソッドの振る舞いは同一クラス内で一貫すべき」 という設計方針に基づいています。Ruby 本体では Hash#filter が Hash#select のエイリアスとして実装されているため、Rails でも同様のエイリアスを提供することで API の予測可能性を高めました。さらに、#filter! / #select! はミュータブル版として既に Hash から継承しているため、追加のオーバーライドは不要です。alias_method による実装はコード量を最小限に抑え、既存の #select ロジックを再利用する安全かつ保守しやすいアプローチです。
まとめ
HashWithIndifferentAccess#filter が HashWithIndifferentAccess を返すようになり、インデファレントアクセスが一貫して利用できるようになりました。エイリアス追加のみで既存挙動と整合性を取ったこの変更は、Rails のハッシュユーティリティの信頼性を向上させ、開発者が期待通りにキーを扱える環境を提供します。